运行时能力

Commands 与 Agent 集成

定义一次命令契约,再复用于统一注册表、可选 Agent Plugin、CLI、HTTP 和 Workbench。

@pluxel/commands 让可携带的业务命令只定义一次输入、输出、副作用等级和执行函数,再由调用方显式发布到 Agent、CLI、HTTP、Workbench 或 carrier。只属于某个 carrier 的命令也使用同一条校验和错误管线,但可以要求该 carrier 构造的扩展 Context,不必进入 root catalog。

在快速开始生成的工作区根目录运行下面命令,选择定义 command 的插件包,然后安装更新后的依赖:

pnpm catalog:add -- @pluxel/commands
pnpm install

已有、不使用 catalog 的项目可以在对应包目录运行 npx nypm add @pluxel/commands

只在多个入口需要复用同一个业务操作时定义 command。普通插件方法可以继续直接调用;HTTP 路由见 插件 HTTP,Agent 权限配置见 Agent tools

1. 定义一个 command


export const  = ({
	: 'job.status.get',
	: 'Job status',
	: 'Read the current status of one job.',
	: { : 'query', : 'closed' },
	: ({
		: .({
			: 'Stable job identifier.',
			: ['cache-refresh'],
		}),
	}),
	: ({
		: .([.('running'), .('stopped'), .('failed')]),
	}),
	: [
		{
			: 'Running job',
			: { : 'cache-refresh' },
			: { : 'running' },
		},
	],
	async ({  }, ) {
		.?.()
		return { : await () }
	},
})

behavior 描述 discovery、confirmation 和 audit policy 使用的静态最坏情况,不授予权限:

  • query 声明 world: 'closed' | 'open'
  • mutation 还必须声明 destructiveidempotent
  • carrier/host 仍负责 principal、permission、确认、rate limit、credential 和 audit。

如果成功没有业务数据,省略 output 并让 execute() 返回 void。若调用方需要 changed、状态或新 ID,就显式声明 output;未声明 output 却返回值会成为 OUTPUT_VALIDATION fault。

在插件中发布并验证

在插件 init() 中注册上面的 definition:

this.ctx.commands.register(jobStatus)

先直接执行一次,验证完整的输入校验与业务结果:

const result = await jobStatus.execute({ jobId: 'cache-refresh' })
// result.status === 'running'

然后在 开发控制台 读取宿主的 command catalog,确认存在 job.status.get。注册只让命令进入可发现清单;是否暴露给 Agent、HTTP 或消息平台是各入口独立的配置。

2. Schema 是唯一公开输入协议

command 的 input 必须是 object schema。可携带 command 的顶层字段是所有目标 carrier 共用的参数,不要再为 Agent、CLI 或 HTTP 维护另一套近似 schema;carrier route 只投影这些字段,不复制 schema。

import { , ,  } from '@pluxel/commands/typebox'

const  = ({
	: .(.({ : 0, : 3 })),
	: .(.()),
	: ({}),
})

obj() 以及嵌套的普通 Type.Object() 默认拒绝额外属性;只有额外 JSON key 本身就是协议时才使用 openObj()。公开 wire value 必须是严格 JSON,不能包含 function、BigIntDate 实例、非有限数、cycle 或 class instance。

需要在实现中使用领域类型时,用 JSON-backed Type.Transform():wire schema 仍是字符串/数字等 JSON,Decode 后的值才进入 validateexecute,output 则先 Encode 再校验。可复用引用应使用自包含的 Type.Module().Import();裸 Type.Ref() 因 descriptor 没有外部 reference registry,会以 COMMAND_CONFIG 拒绝。

字段级 description/examples 用于解释单个值;command examples 表达完整、transport-neutral 的 input/output。不要把 argv 拼写或大段 JSON 塞进 command description。

3. 执行与错误契约

import { CommandError } from '@pluxel/commands'

try {
	const value = await jobStatus.execute(
		{ jobId: 'cache-refresh' },
		{ signal, deadlineMs: Date.now() + 5_000 },
	)
	console.log(value.status)
} catch (error) {
	if (error instanceof CommandError) console.error(error.code, error.publicMessage)
}

raw command 的 execute() 是唯一 validation/codec/business pipeline:它完整执行 wire JSON 检查、schema validation、Decode、自定义 validation、handler、Encode 和 output validation,失败统一抛 CommandError。Carrier 可以在这个 pipeline 得到已校验 output 后执行自己的 presenter,但不能复制或绕过它。deadlineMs 在 pipeline 阶段之间检查;IO 取消要求实现观察 context.signal

需要向 carrier 暴露可预期失败时抛 CommandError

import { CommandError, validation } from '@pluxel/commands'

throw new CommandError('INPUT_VALIDATION', 'Invalid command input', {
	details: {
		issues: [validation.constraint('jobId', 'Job does not exist', { code: 'not_found' })],
	},
})

稳定 code 包括 COMMAND_CONFIGCOMMAND_NOT_FOUNDARGUMENT_SYNTAXINPUT_VALIDATIONOUTPUT_VALIDATIONFORBIDDENABORTEDTIMEOUTDEPENDENCYINTERNAL。carrier 按 code 分支、向用户展示 publicMessagemessagecause 和 diagnostics 只进入可信日志。kind 将配置、输出、依赖和内部错误归为 fault,其余归为 expected

4. Root catalog 与 lifecycle-neutral registry

独立 host,或确实需要 name discovery 的 provider-private catalog,使用 factory 创建 registry:

import { createCommandRegistry } from '@pluxel/commands'

const commands = createCommandRegistry()
const registration = commands.register(jobStatus)

commands.list() // 冻结、按名称排序的 descriptor
commands.snapshot() // { revision, descriptors }
await commands.execute('job.status.get', { jobId: 'cache-refresh' })
await registration.execute({ jobId: 'cache-refresh' }) // 保留精确 output 类型

registration.dispose() // 幂等撤销后续查找与发现

不要 new CommandRegistry() 或 subclass;需要注解时只 import typeregister() 返回可执行的 typed installed command 与 dispose();动态 name dispatch 无法推导具体 output,因此 commands.execute() 返回 unknownsnapshot() 在 catalog 未变时复用同一 immutable identity,list() 就是其 descriptorssubscribe() 只通知之后成功的 publication/withdrawal。Installed handle 是一个会按 name/schema 跟随 compatible replacement 的 catalog slot,不能作为 carrier route 的固定 registration identity。

Pluxel Plugin 应使用 this.ctx.commands.register(jobStatus)。Runtime 原样委托 registry 的 list/snapshot/subscribe/execute,只在 registration 上增加 Plugin owner gate 与 generation effects ownership。stop、replacement、rollback 和 shutdown 会撤销 publication;Core 在 owner 离开 running generation 时统一关闭新 invocation、abort call/owner 组合 signal,并等待已接纳调用退出。手动 dispose 只撤销未来 publication,不取消已经进入执行的调用,也不关闭同 owner 其他 command 的 admission。

5. Carrier publication mount

拥有 argv router、平台 SDK callback 或其他领域 publication surface 的 provider,可以创建一个 owner-bound mount。Mount 是 carrier 实现 primitive,不是普通 consumer 的注册 API。Carrier package 应先用自己的纯 definer 把 direct command、syntax、presenter 和静态 policy 包成领域 declaration,再只公开单参数 registerCommand(declaration)

const echoOnMessage = defineMessageCommand({
	command: echo,
	syntax: { routes: ['echo'], positionals: ['text'] },
	present: (output, { reply }) => reply(output.text),
})

class ConsumerPlugin extends BasePlugin {
	constructor(private readonly messages: MessagePlugin) {
		super()
	}

	override init() {
		this.messages.registerCommand(echoOnMessage)
	}
}

Provider 内部才把 declaration 投影成包含 presenter/error rendering 的 direct command,并交给 mount;这些阶段必须在 mounted execute() 返回前完成:

import type { CommandContext, Registration } from '@pluxel/commands'
import { createArgvRouter } from '@pluxel/commands/argv'
import { BasePlugin, type CommandMount } from '@pluxel/runtime'

interface MessageCommandContext extends CommandContext {
	readonly reply: (text: string) => Promise<void>
}

class MessagePlugin extends BasePlugin {
	private readonly router = createArgvRouter<MessageCommandContext>()
	private mount!: CommandMount<MessageCommandContext>

	protected override init() {
		this.mount = this.ctx.commands.createMount<MessageCommandContext>()
	}

	registerCommand<I, O>(definition: MessageCommandDefinition<I, O>): Registration {
		const router = this.router
		const projected = projectMessageCommand(definition)
		return this.mount.bind(projected, (owned) => router.bind(owned, definition.syntax))
	}
}

defineMessageCommand()MessageCommandDefinitionprojectMessageCommand() 在这里代表 carrier 自己拥有的领域 API/实现,不是 Runtime export。普通 consumer 只调用 provider 定义的 definer 与 registerCommand(),不直接取得 mount,也不传裸 Context。dependency facade 读取 provider 的 mount field 时,Runtime 自动固定 publication owner。每次执行同时受 provider 与 publication owner generation gate 保护,任一方 stop/replacement 都拒绝新调用,并取消、drain 已接纳调用。手动 dispose 只撤销未来 publication,不取消已经开始的调用。

扩展 Context 是每次调用创建、只含 enumerable own data property 的结构化 capability record,不使用 class prototype 或 non-enumerable field。reply 等函数成员应使用闭包,不依赖 Context 对象作为 this;Runtime 会复制该 record,并在不修改调用方对象的前提下用 provider、publication owner 与 call signal 的组合结果替换 signal

bind() 只接受 DirectCommand:普通 defineCommand() 结果可以直接挂载,registry register() 返回的 InstalledCommand/CommandRegistration 不可以。后者会按 name 和 schema 跟随 compatible replacement,不适合作为一条 route、presenter 和 owner generation 的固定 identity。这个限制在 TypeScript 中是 misuse guard;Runtime 仍做运行时校验。

Mount 在 bind 时复制、冻结 name/descriptor snapshot,并捕获当时的 execute 函数与原 command receiver。之后替换 command.execute 不会改变已挂载实现,但 hand-authored method 仍取得原 receiver;“direct”不代表 deep-freeze receiver 的其他可变状态。Direct definition 也不能带 dispose member,lifecycle cleanup 只属于 publication registration。

Mount 的 install 必须同步返回一个幂等、同步、no-throw 的 Registration。Mount 只管理 exact command、双 owner admission 与撤销事务,不提供 list/snapshot/subscribe/name lookup,也不建立 secondary registry。Carrier 的 route syntax、admission、invocation context、success presenter 和 error renderer 留在 carrier;会触碰 invocation capability 的阶段必须在 mounted command 返回前 settle。Root catalog publication 与 carrier publication 是两个独立决定:需要同时暴露时,分别调用 ctx.commands.register(raw) 与 carrier 的 registerCommand(definition)

6. Agent tool 投影与 allowlist

普通 host 从同一 descriptor snapshot 生成 provider 自己的 tool 描述:

const visible = commands.list().filter((descriptor) => policy.allows(descriptor.name))
const tools = visible.map((descriptor) => provider.projectCommand(descriptor))

provider adapter 自己映射 name、title、description、input/output JSON Schema 和 behavior。MCP annotation、task support、provider 重命名与反向 name mapping 都是 carrier 契约,不是 command kernel 的公开概念。

需要持久化 Agent allowlist 时安装可选官方 Plugin @pluxel/agent-tools。Toolset 与 Agent assignment 是它的普通 Plugin config:ConfigService 负责校验、持久化和通用配置页面,Runtime 不安装 Agent capability,也不维护第二套 policy store 或 Management RPC。

import { AgentToolsPlugin } from '@pluxel/agent-tools'

const catalog = agentTools.catalog(agentId)
const tools = catalog.list().map((descriptor) => provider.projectCommand(descriptor))
const result = await catalog.execute(toolName, candidate, invocationContext)

Agent adapter 应是通过 constructor required dependency 取得 AgentToolsPlugin 的普通 Plugin。发布和执行必须使用同一个 bound catalog;它会在调用时再次检查 assignment,并提供包含 catalogRevision/policyRevision 的 snapshot 与订阅能力。Plugin stop/replacement 后旧 catalog 立即撤销。adapter 不应在收到 tool call 后绕过它调用裸 ctx.commands.execute()

Toolset 只保存稳定 command name,不复制 descriptor 或 handler。暂时不存在的 name 会保留在 config,之后同名 command 发布时自动进入投影。MCP、OpenAI、Claude 等 provider schema、tool name 映射、principal、确认与审计仍由 adapter 自己负责。完整用法见 Agent tools Plugin

需要直接内置 Agent engine 时,workspace preview @pluxel/pi-agent 会把同一个 bound catalog 投影给 Pi,并保持 Pluxel 作为唯一 Plugin runtime 与权限边界。

7. argv/message grammar

只有确实需要人类友好的 route、alias、positionals 或 tail 时才使用自定义 router。下面是 standalone/router primitive 用法;Runtime Plugin carrier 应在 provider 内把 CommandMount 给出的 owned command 绑定到 router,而不是让普通 consumer 直接绑定 raw/installed command:

import { createArgvRouter } from '@pluxel/commands/argv'

const argv = createArgvRouter()
argv.bind(jobStatus, {
	routes: ['job status', 'status'],
	positionals: ['jobId'],
})

const resolution = argv.resolve(process.argv.slice(2))
if (resolution) {
	await resolution.command.execute(resolution.candidate)
}

把 shell 已 tokenized 的 string[] 原样传入;不要先 join(' '),否则会丢失 quoting 边界。raw chat/message 文本可以直接传 string,由 router tokenize 一次。

createArgvRouter().bind() 只把已有 object fields 映射为语法:

  • routes:第一项是 canonical route,其余为 alias;最长前缀匹配;
  • positionals:每项消费一个 token;
  • options:可改 long name、aliases 和 help,未列出的标量仍生成 option;
  • tail.text(key):把余下文本交给 string-backed field;
  • tail.json(key):把余下文本解析成一个 JSON value。

string、number、integer、boolean、string enum 和 scalar array 可自动成为 option。object、union 等复杂字段不会被猜测,必须显式设置 { format: 'json' }。positionals/tail/options 不能重复占用同一字段,冲突和 unsupported schema 会在 bind() 时以 COMMAND_CONFIG 失败。

argv.bind(patchConfig, {
	routes: ['settings patch'],
	positionals: ['scope'],
	options: { patch: { format: 'json' } },
	tail: tail.text('reason', '[reason]'),
})

resolve() 只负责 route match 与 candidate 构造;carrier 再完成授权、context 组装、mounted command.execute() 与结果呈现。list() 提供 frozen metadata 给 host 自己的 help/completion renderer。默认大小写不敏感、文本上限 16 KiB,unknown option 和 enum typo 会给出稳定 suggestions;unknown route 不会在 policy 过滤前泄漏其他 command。

公开入口

需求入口
Plugin 发布到 root catalogthis.ctx.commands.register(command)
Carrier provider 建 publication 面this.ctx.commands.createMount()
独立 host 建 catalogcreateCommandRegistry()
Agent allowlistagentTools.catalog(agentId)
自定义 route/positionals/tailcreateArgvRouter().bind()

carrier 负责授权、确认、principal 映射、输出格式和进程退出码;command definition 与 runtime registry 不承担这些宿主策略。

最后更新于

本页目录