Rename Cordis tools for temporary plugins

Clarify process-local lifecycle semantics and refresh generated documentation, demos, and snapshots.
This commit is contained in:
NI0317
2026-07-27 16:57:26 +08:00
parent 5be0118784
commit b4a1304489
73 changed files with 1807 additions and 1002 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: b3a70b07c57ae1a2e3dd975a64c840d90c5a84ad
README.zh.md: 5832310cfe14a299ac5998a28bcbbb500caf86b5
# pnpm run verify-translation-pairing --write packages/cordis/README.md
README.md: bcd11230cdaaaf2893bcc64eadfe689855bc3553
README.zh.md: c26424f08dced26aa60d0a21202449e1b3b16860

View File

@@ -6,4 +6,4 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in
| Package | Role | ctx key |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the runtime, evaluate model-written plugin code in a `node:vm` sandbox, and manage the dynamic mounts under one group fiber | registers on `ctx.tools` |
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_try` / `cordis_stop` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` |

View File

@@ -2,8 +2,8 @@
[English](README.md) | 中文
面向模型、作用于 agent智能体自身所在实时 Cordis 运行时的工具:检查已加载插件与服务接口、挂载模型编写的插件,以及再次释放这些插件。设计归档见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
面向模型、作用于 agent智能体所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并尝试或停止仅存于内存的临时 Plugin。设计归档见[工具集 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
| 包package | 角色 | ctx 键 |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取运行时、在 `node:vm` 沙箱中求值模型编写的插件代码,并在一个分组 fiber 下管理动态挂载 | 注册到 `ctx.tools` |
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_try``cordis_stop` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` |

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 022e25decad650e03aa621cfa2b3f33ccc8d9743
README.zh.md: 99a79209a2a895ee8e11e3b7345a305da514ac1e
# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md
README.md: fda296817026556f235d42626e87fe1f361f2c36
README.zh.md: b8e3c02ba43a7c366664f5964168798ee7356186

View File

@@ -2,17 +2,19 @@
English | [中文](README.zh.md)
The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The self-referential Cordis toolset: three model-facing tools over the live runtime in the current DSH process. Design home — sandbox semantics, temporary-plugin lifecycle and composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## What it does
- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-<n>`.
- `cordis_unmount` — disposes one mount by id, returning only after quiescence.
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_try` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc.
- `cordis_try` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-<n>`.
- `cordis_stop` — stops one `dyn-<n>` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins.
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`.
Canonical successes are the inspection string, try `{ id, pluginName, state, provides, waitingFor }`, and stop `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until stopped or DSH restarts; stop confirms that it was stopped and removed.
Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_stop`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow.
## Trust stance
@@ -22,7 +24,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
| Field | Default | Meaning |
|---|---|---|
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of mount-code evaluation; an async body escapes it |
| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of temporary-Plugin code evaluation; an async body escapes it |
## The generated API catalog
@@ -30,7 +32,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
## Rendering
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the mount code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_try` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering.
## Export shape
@@ -42,7 +44,7 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau
#### What the model sees
The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
The conversation model sees the generated [`cordis_inspect`, `cordis_try`, and `cordis_stop` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
#### Token effect
@@ -56,7 +58,7 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha
#### What the model sees
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted <id> (plugin "<name>", state: <state>)`, optionally inserting ` — waiting for service(s): <names> (activates when provided)` before the closing parenthesis. Unmount returns `unmounted <id> (plugin "<name>")`; an unknown id becomes `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history.
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until stopped or DSH restart. The empty state explains that `cordis_try` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Try returns `Temporary Plugin <id> is running (...)` or `Temporary Plugin <id> is pending (...)`; stop returns `Temporary Plugin <id> was stopped and removed.` The submitted program remains in assistant tool-call history.
#### Token effect
@@ -66,19 +68,19 @@ Inspect output and mount code are data-dependent and resent until compaction; li
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Later requests after a mount
### Later requests after cordis_try
#### What the model sees
A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence.
A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` removes those contributions after quiescence.
#### Token effect
Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime.
Indirect token impact equals the temporary Plugin's contributions and lasts only for its process-local lifetime.
#### KV Cache effect
Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable.
Trying or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable.
## Known Limitations and Deferred Work

View File

@@ -2,17 +2,19 @@
[English](README.md) | 中文
自引用 cordis 工具集:三个面向模型的工具,操作 agent 所处的存活运行时。设计归属(沙箱语义、挂载生命周期、跨挂载组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的存活运行时。设计归属(沙箱语义、临时 Plugin 生命周期与组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
## 功能
- `cordis_inspect`:运行时的只读报告,包括服务、已加载插件列表、已注册工具、动态挂载表,以及目录支持的 `api``events` 参考。精确的 `name` 配合 `what: "api"``what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
- `cordis_mount`:在 `node:vm` 沙箱中求值模型编写的 JavaScript一个 async 函数的主体);代码必须 `return` 一个 cordis 插件,系统将其挂载在 `cordis-dynamic` 分组 fiber 下,并以 `dyn-<n>` 跟踪
- `cordis_unmount`:按 id 释放一项挂载,只在完全停稳后返回
- `cordis_inspect`当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_try` 临时 Plugin 子集,以及目录支持的 `api``events` 参考。精确的 `name` 配合 `what: "api"``what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。
- `cordis_try`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-<n>` 跟踪、仅存于内存的临时 Plugin
- `cordis_stop`:停止一个 `dyn-<n>` 临时 Plugin并只在其自有效果完全停稳后返回它不能删除 Loader、配置或已安装的 Plugin
精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 保留现有文本,因此程序可以使用 `mounted.id`,普通 Function Calling 仍会看到 `mounted dyn-1 (...)`
规范成功值分别为检查字符串、尝试 `{ id, pluginName, state, provides, waitingFor }`,以及停止 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被停止或 DSH 重启;停止结果确认它已停止并移除
临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session但会在 `cordis_stop`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。
## 信任立场
@@ -22,7 +24,7 @@
| 字段 | 默认值 | 含义 |
|---|---|---|
| `vmTimeoutMs` | `5000` | 挂载代码求值中同步部分的边界async 主体可逃出该边界 |
| `vmTimeoutMs` | `5000` | 临时 Plugin 代码求值中同步部分的边界async 主体可逃出该边界 |
## 生成的 API 目录
@@ -30,7 +32,7 @@
## 渲染
三个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_mount``rawInput` 携带挂载代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
三个工具都渲染 `generic` 卡片(`read``execute``delete``cordis_try``rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。
## 导出形状
@@ -42,7 +44,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_try` 和 `cordis_stop` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
#### Token 影响
@@ -56,7 +58,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### 模型看到的内容
检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `mounted <id> (plugin "<name>", state: <state>)`,并可在右括号前插入 ` — waiting for service(s): <names> (activates when provided)`。卸载返回 `unmounted <id> (plugin "<name>")`;未知 id 会变成 `Error: no dynamic plugin with id "<id>" (list mounts with cordis_inspect what:"dynamic")`。提交的挂载程序保留在 assistant 工具调用历史中。
检查会精确地用 `## <section>` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 runningpending 状态、提供与等待的服务,以及持续至停止或 DSH 重启的生命周期;空状态说明 `cordis_try` Plugin 会在重启时消失。宽泛的 API事件报告省略 JSDoc`name` 配合 `what: "api"``what: "events"` 返回一个精确目标及其原始 JSDoc。尝试结果为 `Temporary Plugin <id> is running (...)``Temporary Plugin <id> is pending (...)`;停止结果为 `Temporary Plugin <id> was stopped and removed.`。提交的程序保留在 assistant 工具调用历史中。
#### Token 影响
@@ -66,19 +68,19 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
### 挂载后的后续请求
### cordis_try 后的后续请求
#### 模型看到的内容
已挂载插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;卸载会在完全停稳后移除这些贡献。
临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop` 会在完全停稳后移除这些贡献。
#### Token 影响
间接 token 影响等于已挂载插件的贡献,且只在挂载生命周期内持续。
间接 token 影响等于临时 Plugin 的贡献,且只在其进程内生命周期内持续。
#### KV Cache 影响
挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;挂载集合不变时,前缀保持稳定。
尝试或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。
## 已知限制与暂缓事项

View File

@@ -684,7 +684,7 @@ function sandboxContext(ctx: Context): Context {
if (ctx.get(prop) !== undefined) {
throw new Error(
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
+ 'so cordis parks this mount if the provider is later unmounted.',
+ 'so cordis parks this temporary Plugin if the provider later stops.',
)
}
throw new Error(

View File

@@ -1,6 +1,6 @@
/**
* Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin
* under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects,
* Self-referential runtime tools: inspect live services/plugins/tools, try a returned temporary
* plugin under an owned dynamic fiber, and stop it to quiescence. Registrations are fiber effects,
* so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent
* accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real
* runtime. Named exports preserve loader injection metadata.
@@ -15,7 +15,7 @@ import { isPlugin, pluginName } from './guard.ts'
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts'
import { missingServices, mountDynamic, type DynamicMount } from './mount.ts'
import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts'
import { presentInspectCall, presentStopCall, presentTryCall } from './present.ts'
import { createSandbox, evaluateMountCode } from './sandbox.ts'
export const name = 'tool-cordis'
@@ -40,8 +40,8 @@ export const Config: z<Config> = z.object({
type ResolvedConfig = Required<Config>
/**
* Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic`
* group fiber every dynamic mount hangs under.
* Register the three cordis tools and own every temporary plugin under one
* `cordis-dynamic` group fiber.
* @param ctx - the plugin context (`tools` injected).
* @param config - the schemastery-resolved {@link Config}.
*/
@@ -56,19 +56,21 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'cordis_inspect',
description:
'Inspect the live cordis runtime that is running THIS agent. Read-only. '
'Inspect the live Cordis runtime in the current DSH process. Read-only. '
+ 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), '
+ '`plugins` (a flat list of the loaded plugins with their lifecycle states), '
+ '`plugins` (all live plugin fibers with their lifecycle states), '
+ '`tools` (the model-facing tools currently registered, i.e. what you can call), '
+ '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), '
+ '`temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), '
+ '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), '
+ '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). '
+ 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` '
+ 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. '
+ 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. '
+ 'With `what:"api"` or `what:"events"`, pass an exact `name` '
+ 'to narrow to one service/event and include its original source JSDoc.',
parameters: {
what: {
type: 'string',
enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'],
enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'],
description: 'Limit the report to one section. Omit for all sections.',
},
name: {
@@ -84,19 +86,19 @@ export function apply(ctx: Context, config: Config): void {
if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') {
throw new Error('name is valid only with what:"api" or what:"events"')
}
const sections: [heading: string, body: () => string[]][] = [
['services', () => describeServices(ctx)],
['plugins', () => describePlugins(ctx)],
const sections: [key: string, heading: string, body: () => string[]][] = [
['services', 'services', () => describeServices(ctx)],
['plugins', 'plugins', () => describePlugins(ctx)],
// The calling agent's view: scoped/shadowed tools included, restricted
// globals absent — "what you can call", not the global registry.
['tools', () => describeTools(ctx, exec.agent)],
['dynamic', () => describeDynamic(ctx, mounts)],
['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', () => describeEvents(EVENT_API, args.name)],
['tools', 'tools', () => describeTools(ctx, exec.agent)],
['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)],
['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)],
['events', 'events', () => describeEvents(EVENT_API, args.name)],
]
const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading)
const selected = sections.filter(([key]) => args.what === undefined || args.what === key)
const text = selected
.map(([heading, body]) => `## ${heading}\n${body().join('\n')}`)
.map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`)
.join('\n\n')
return Promise.resolve(text)
},
@@ -104,10 +106,15 @@ export function apply(ctx: Context, config: Config): void {
}))
ctx.tools.register(defineTool({
name: 'cordis_mount',
name: 'cordis_try',
description:
'Mount a NEW cordis plugin into the live runtime that is running THIS agent '
+ '(self-modification). `code` runs as the body of an async JavaScript function '
'Try a temporary Cordis Plugin in the current DSH process. '
+ 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. '
+ 'It remains active across later turns until cordis_stop, toolset unload, or DSH restart. '
+ 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. '
+ 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. '
+ 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. '
+ '`code` runs now as the body of an async JavaScript function '
+ 'in an isolated sandbox and MUST `return` a plugin. Two forms: '
+ 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register '
+ 'tools, listen to events, and provide services, but reaching ANY service (e.g. '
@@ -116,7 +123,7 @@ export function apply(ctx: Context, config: Config): void {
+ '— declares dependencies, and cordis activates the plugin only after the '
+ 'services exist; PREFER this form. You may reach ONLY the services you list in '
+ 'inject: an undeclared service throws even if it exists, because an undeclared '
+ 'dependency would not be cleaned up if its provider is unmounted. '
+ 'dependency would not be cleaned up if its provider stops. '
+ 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists '
+ 'method signatures AND the type shapes of their arguments/returns (do not guess a '
+ 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). '
@@ -131,10 +138,10 @@ export function apply(ctx: Context, config: Config): void {
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; '
+ '`output.render(args, value)` separately returns Native/model content blocks. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '
+ 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and '
+ 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending '
+ 'until the provider exists and returns to pending when the provider is unmounted. '
+ 'Everything registered inside `apply` is cleaned up automatically on unmount. '
+ 'until the provider exists and returns to pending when the provider stops. '
+ 'Everything registered inside `apply` is cleaned up automatically by cordis_stop. '
+ 'Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness '
+ 'terminal), `harness.defineTool`, `harness.registerTool`, '
+ '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. '
@@ -143,7 +150,7 @@ export function apply(ctx: Context, config: Config): void {
+ 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for '
+ 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, '
+ 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, '
+ 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. '
+ 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). '
+ 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a '
+ 'trailing `next` callback which MUST be called — returning without `next()` '
@@ -159,7 +166,7 @@ export function apply(ctx: Context, config: Config): void {
code: {
type: 'string',
required: true,
description: 'Body of an async JS function; must `return` the plugin to mount.',
description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.',
},
},
output: {
@@ -179,12 +186,12 @@ export function apply(ctx: Context, config: Config): void {
},
},
render: (_args, value) => {
const note = value.waitingFor.length > 0
? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)`
: ''
const status = value.waitingFor.length > 0
? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}`
: `is running (plugin "${value.pluginName}"`
return [{
type: 'text',
text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`,
text: `Temporary Plugin ${value.id} ${status}; available until stopped or DSH restarts).`,
}]
},
},
@@ -195,13 +202,13 @@ export function apply(ctx: Context, config: Config): void {
if (!isPlugin(evaluated)) {
if (evaluated === undefined) {
throw new Error(
'mount code returned `undefined` — did you forget `return`?\n'
'temporary Plugin code returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }',
)
}
throw new Error(
'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method',
'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method',
)
}
const fiber = await mountDynamic(group, evaluated)
@@ -219,21 +226,19 @@ export function apply(ctx: Context, config: Config): void {
waitingFor: missing,
}
},
presentCall: presentMountCall,
presentCall: presentTryCall,
}))
ctx.tools.register(defineTool({
name: 'cordis_unmount',
name: 'cordis_stop',
description:
'Dispose a plugin previously mounted with cordis_mount, by id. All its '
+ 'registrations (event listeners, tools, services) are cleaned up through '
+ 'the cordis effect lifecycle. Returns only after disposal has fully '
+ 'completed (quiescence, not just a request to stop).',
'Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. '
+ 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.',
parameters: {
id: {
type: 'string',
required: true,
description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").',
description: 'The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart.',
},
},
output: {
@@ -245,17 +250,17 @@ export function apply(ctx: Context, config: Config): void {
pluginName: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }],
render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was stopped and removed.` }],
},
async execute(args) {
const mount = mounts.get(args.id)
if (!mount) {
throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`)
throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`)
}
await mount.fiber.dispose()
mounts.delete(args.id)
return { id: args.id, pluginName: mount.pluginName }
},
presentCall: presentUnmountCall,
presentCall: presentStopCall,
}))
}

View File

@@ -1,6 +1,6 @@
/**
* Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat
* plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits),
* plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits),
* and the catalog-backed `api` / `events` sections. Exact-name lookups add the
* original source JSDoc without inflating the default reports.
* @module @deepseek-ai/dsh-tool-cordis/inspect
@@ -63,8 +63,8 @@ export function describeServices(ctx: Context): string[] {
/**
* The `plugins` section: a flat list of every fiber the registry knows, one
* line per fiber with its lifecycle state, sorted by plugin name (a plugin
* mounted more than once repeats — one line per instance). Dynamic mounts are
* listed like any other plugin; their ids live in the `dynamic` section.
* mounted more than once repeats — one line per instance). Temporary plugins are
* listed like any other plugin; their ids live in the `temporary` section.
* @param ctx - the runtime whose registry is enumerated.
* @returns one line per loaded plugin fiber.
*/
@@ -91,7 +91,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
}
/**
* The `dynamic` section: one line per mount with id, plugin name, lifecycle
* The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle
* state, the services its subtree provides, and — for a pending mount — the
* services it waits for.
* @param ctx - the runtime the mounts live in.
@@ -99,13 +99,14 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
* @returns one line per mount, or a single placeholder line when none exist.
*/
export function describeDynamic(ctx: Context, mounts: ReadonlyMap<string, DynamicMount>): string[] {
if (mounts.size === 0) return ['(no dynamic plugins mounted)']
if (mounts.size === 0) {
return ['No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.']
}
return [...mounts].map(([id, mount]) => {
const provides = providedServices(ctx, mount.fiber)
const waiting = missingServices(ctx, mount.fiber)
const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : ''
const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : ''
return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}`
const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state]
return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until stopped or DSH restarts`
})
}

View File

@@ -39,8 +39,8 @@ export async function mountDynamic(group: Fiber, plugin: Plugin): Promise<Fiber>
// while the old mount still holds the name — teach the replace recipe.
if (message.includes('already registered')) {
throw new Error(
`${message} — to REPLACE something an earlier mount registered, first cordis_unmount that mount's id `
+ '(find it with cordis_inspect what:"dynamic"), then mount the new version.',
`${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_stop that Plugin's id `
+ '(find it with cordis_inspect what:"temporary"), then try the new version.',
)
}
throw error instanceof Error ? error : new Error(message)

View File

@@ -25,28 +25,28 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene
}
/**
* The `cordis_mount` call card: an execute carrying the mount code as raw input.
* The `cordis_try` call card: an execute carrying the temporary-plugin code as raw input.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentMountCall(args: { code: string }): GenericCallView {
export function presentTryCall(args: { code: string }): GenericCallView {
return {
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
title: 'Try temporary Cordis Plugin',
rawInput: { code: args.code },
}
}
/**
* The `cordis_unmount` call card: a delete, titled with the mount id.
* The `cordis_stop` call card: a delete, titled with the temporary-plugin id.
* @param args - the validated call arguments.
* @returns the generic call card.
*/
export function presentUnmountCall(args: { id: string }): GenericCallView {
export function presentStopCall(args: { id: string }): GenericCallView {
return {
card: 'generic',
kind: 'delete',
title: `Unmount ${args.id}`,
title: `Stop temporary Cordis Plugin ${args.id}`,
}
}

View File

@@ -1,5 +1,5 @@
/**
* The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a
* The `node:vm` sandbox `cordis_try` code evaluates in: a fresh realm whose globals are a
* tagged write-through console, the `harness` registration helpers, the encoding primitives a
* bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
@@ -52,7 +52,7 @@ function patchDualRealmInstanceof(sandbox: object): void {
const TIMER_REDIRECT
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.'
+ 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when stopped.'
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
@@ -80,14 +80,14 @@ function nodeApiTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
traps[name] = () => {
throw new Error(`${name} is not available in the mount sandbox — ${redirect}`)
throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`)
}
}
return traps
}
/**
* Build the vm context one `cordis_mount` call evaluates in: the tagged
* Build the vm context one `cordis_try` call evaluates in: the tagged
* console, the `harness` registration helpers, the encoding primitives, the
* Node-API traps, and the dual-realm `instanceof` patch, already
* `createContext`-ed.
@@ -163,14 +163,14 @@ export async function evaluateMountCode(sandbox: object, code: string, id: strin
const offendingLine = context.split('\n')[1] ?? ''
if (/\bas\b/.test(offendingLine)) {
throw new Error(
`mount code failed to parse:\n${context}\n`
`temporary Plugin code failed to parse:\n${context}\n`
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
+ ' ✗ { type: \'text\' as const, text: x }\n'
+ ' ✓ { type: \'text\', text: x }',
)
}
throw new Error(
`mount code failed to parse:\n${context}\n`
`temporary Plugin code failed to parse:\n${context}\n`
+ 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.',

View File

@@ -11,12 +11,12 @@ import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text }
describe('cross-mount provide/inject', () => {
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
expect(text(provider)).toContain('state: active')
const provider = await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
expect(text(provider)).toContain('is running')
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: active')
expect(text(consumer)).toContain('is running')
// The vm-realm service value is callable across mounts, and the result
// normalizes into the host realm like any dynamic tool result.
@@ -27,62 +27,62 @@ describe('cross-mount provide/inject', () => {
it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => {
const ctx = await setup()
const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE })
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: pending')
expect(text(consumer)).toContain('waiting for service(s): greeter')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter')
expect(text(consumer)).toContain('is pending')
expect(text(consumer)).toContain('missing services: greeter')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter')
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late')
})
it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2
expect(ctx.tools.get('greet')).toBeDefined()
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
expect(ctx.tools.get('greet')).toBeUndefined()
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter')
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until stopped or DSH restarts')
})
it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_stop', { id: 'dyn-1' })
expect(ctx.tools.get('greet')).toBeUndefined()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-3
expect(ctx.tools.get('greet')).toBeDefined()
expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]')
})
it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
const duplicate = await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
expect(duplicate.isError).toBe(true)
expect(text(duplicate)).toContain('has been registered')
const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(report).toContain('dyn-1: greeter-provider')
const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(report).toContain('Temporary Plugin dyn-1: greeter-provider')
expect(report).not.toContain('dyn-2')
})
it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE })
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE })
await call(ctx, 'cordis_try', { code: PROVIDER_CODE })
await call(ctx, 'cordis_try', { code: CONSUMER_CODE })
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))
expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter')
const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))
expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until stopped or DSH restarts')
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
@@ -93,7 +93,7 @@ describe('cross-mount provide/inject', () => {
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
const ctx = await setup()
const provider = await call(ctx, 'cordis_mount', {
const provider = await call(ctx, 'cordis_try', {
code: `
return {
name: 'answer-provider',
@@ -106,7 +106,7 @@ describe('cross-mount provide/inject', () => {
})
expect(provider.isError).toBe(false)
const consumer = await call(ctx, 'cordis_mount', {
const consumer = await call(ctx, 'cordis_try', {
code: `
return {
name: 'answer-consumer',
@@ -126,19 +126,19 @@ describe('cross-mount provide/inject', () => {
`,
})
expect(consumer.isError).toBe(false)
expect(text(consumer)).toContain('state: active')
expect(text(consumer)).toContain('is running')
expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null')
})
it('unmounting the consumer leaves the provider and its service intact', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_unmount', { id: 'dyn-2' })
await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1
await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2
await call(ctx, 'cordis_stop', { id: 'dyn-2' })
expect(ctx.tools.get('greet')).toBeUndefined()
const services = text(await call(ctx, 'cordis_inspect', { what: 'services' }))
expect(services).toContain('- greeter (provided by greeter-provider)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-1: greeter-provider [running]')
})
})

View File

@@ -18,7 +18,7 @@ describe('cordis_inspect', () => {
const report = text(result)
if (result.isError) throw new Error('expected cordis_inspect success')
expect(result.value).toBe(report)
for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) {
for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) {
expect(report).toContain(`## ${heading}`)
}
// The services section sees the real providers; the plugins list shows
@@ -27,8 +27,8 @@ describe('cordis_inspect', () => {
expect(report).toContain('- tools (provided by ToolRegistry)')
expect(report).toContain('- tool-cordis [active]')
expect(report).toContain('- cordis-dynamic [active]')
expect(report).toContain('- cordis_mount')
expect(report).toContain('(no dynamic plugins mounted)')
expect(report).toContain('- cordis_try')
expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.')
})
it('limits the report to one section via `what`', async () => {
@@ -40,11 +40,12 @@ describe('cordis_inspect', () => {
expect(report).not.toContain('## plugins')
})
it('shows a mount in the dynamic section and in the flat plugins list', async () => {
it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
const report = text(await call(ctx, 'cordis_inspect', {}))
expect(report).toContain('- dyn-1: change-logger [active]')
expect(report).toContain('## Temporary Plugins')
expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts')
expect(report).toContain('- change-logger [active]')
})

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { REVERSE_TOOL_CODE } from './helpers.ts'
import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* Full-loop integration: a scripted mock model mounts a plugin that registers
@@ -39,9 +40,9 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
describe('cordis tools through the agent loop', () => {
it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-1', 'cordis_try', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
toolCallResponse('call-3', 'cordis_stop', { id: 'dyn-1' }),
textResponse('Done.'),
])
const ctx = await harness(adapter)
@@ -52,7 +53,7 @@ describe('cordis tools through the agent loop', () => {
const log = agent.session.events
const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
expect(calls).toEqual(['cordis_try', 'reverse_text', 'cordis_stop'])
const results = log.filter(event => event.type === 'tool/result')
expect(results.map(event => event.data.isError)).toEqual([false, false, false])
@@ -65,4 +66,36 @@ describe('cordis tools through the agent loop', () => {
// After the unmount the self-made tool is gone from the registry.
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('keeps a temporary Plugin across turns, stops it, and does not restore it in a new runtime', async () => {
const adapter = new MockAdapter([
toolCallResponse('try-1', 'cordis_try', { code: 'return { name: \'turn-marker\', apply() {} }' }),
toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }),
textResponse('Turn one complete.'),
toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }),
toolCallResponse('stop-1', 'cordis_stop', { id: 'dyn-1' }),
toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }),
textResponse('Turn two complete.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'Try the marker and inspect it.' }])
await waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'On this later turn, inspect the marker, stop it, then inspect again.' }])
await waitForIdle(ctx, agent)
const resultText = new Map(
agent.session.events
.filter(event => event.type === 'tool/result')
.map(event => [event.data.callId, event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')]),
)
expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]')
expect(resultText.get(CallId('stop-1'))).toBe('Temporary Plugin dyn-1 was stopped and removed.')
expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.')
const restarted = await setup()
expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
})

View File

@@ -5,7 +5,7 @@ import { syntaxErrorContext } from '../src/sandbox.ts'
import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* The `cordis_mount` success/failure family: real plugins land on a genuine
* The `cordis_try` success/failure family: real plugins land on a genuine
* cordis fiber tree, their registrations are observable through the real
* registry/event bus, and every rejection path teaches the fix.
*/
@@ -14,7 +14,7 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('cordis_mount', () => {
describe('cordis_try', () => {
it.each([
[42, 'options must be an object'],
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
@@ -47,9 +47,9 @@ describe('cordis_mount', () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
const result = await call(ctx, 'cordis_try', { code: LISTENER_CODE })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_mount success')
if (result.isError) throw new Error('expected cordis_try success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'change-logger',
@@ -57,7 +57,7 @@ describe('cordis_mount', () => {
provides: [],
waitingFor: [],
})
expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)')
expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until stopped or DSH restarts).')
// Fire a REAL tools/change by registering a tool; the mounted listener logs.
ctx.tools.register(dummyTool('trigger_a'))
@@ -66,16 +66,16 @@ describe('cordis_mount', () => {
it('mounts a bare-function plugin as <anonymous>, and a named function under its name', async () => {
const ctx = await setup()
const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
const anonymous = await call(ctx, 'cordis_try', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' })
expect(anonymous.isError).toBe(false)
expect(text(anonymous)).toContain('plugin "<anonymous>"')
const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' })
const named = await call(ctx, 'cordis_try', { code: 'return function watcher(ctx) {}' })
expect(text(named)).toContain('plugin "watcher"')
})
it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
const result = await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
expect(result.isError).toBe(false)
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text')
@@ -89,14 +89,14 @@ describe('cordis_mount', () => {
it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => {
// VM-realm objects fail the session prototype-identity check; normalize them into host JSON.
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
const reversed = await call(ctx, 'reverse_text', { text: 'harness' })
expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true)
})
it('projects presentation metadata from a dynamic canonical value', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'meta-return',
@@ -136,7 +136,7 @@ describe('cordis_mount', () => {
['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'],
])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'bad-return',
@@ -162,7 +162,7 @@ describe('cordis_mount', () => {
it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'huge-return',
@@ -189,7 +189,7 @@ describe('cordis_mount', () => {
// These common JSON-Schema spellings each have one DSL meaning, so normalize rather than
// consume another model turn with a rejection.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'json-schema-tool',
@@ -245,7 +245,7 @@ describe('cordis_mount', () => {
// On an object PROPERTY, a JSON-Schema-style `required` array names the
// required children — the nested unwrap converts it just like the top level.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'nested-json-schema',
@@ -276,7 +276,7 @@ describe('cordis_mount', () => {
it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'unified-schema',
@@ -324,7 +324,7 @@ describe('cordis_mount', () => {
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
const ctx = await setup()
const depth = 5_000
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'deep-unified-schema',
@@ -377,7 +377,7 @@ describe('cordis_mount', () => {
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'raw-unified-schema',
@@ -467,7 +467,7 @@ describe('cordis_mount', () => {
['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'bad-schema',
@@ -508,7 +508,7 @@ describe('cordis_mount', () => {
],
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'circular-schema',
@@ -531,7 +531,7 @@ describe('cordis_mount', () => {
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'proto-schema',
@@ -566,7 +566,7 @@ describe('cordis_mount', () => {
it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'nested-schema',
@@ -593,7 +593,7 @@ describe('cordis_mount', () => {
it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'raw-register',
@@ -618,7 +618,7 @@ describe('cordis_mount', () => {
it('guards the registry reached through ctx.get(\'tools\') identically', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'raw-register-get',
@@ -636,13 +636,13 @@ describe('cordis_mount', () => {
it('passes non-register registry members through the guard with correct binding', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'schema-reader',
inject: ['tools'],
apply(ctx) {
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount'))
console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_try'))
},
}
`,
@@ -653,11 +653,11 @@ describe('cordis_mount', () => {
it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }',
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected pending cordis_mount success')
if (result.isError) throw new Error('expected pending cordis_try success')
expect(result.value).toEqual({
id: 'dyn-1',
pluginName: 'waiter',
@@ -665,64 +665,63 @@ describe('cordis_mount', () => {
provides: [],
waitingFor: ['no-such-service'],
})
expect(text(result)).toContain('state: pending')
expect(text(result)).toContain('waiting for service(s): no-such-service')
expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until stopped or DSH restarts).')
// Unmounting a pending mount works like any other.
const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
expect(unmounted.isError).toBe(false)
})
it('rejects code that throws, leaving nothing mounted', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' })
const result = await call(ctx, 'cordis_try', { code: 'throw new Error(\'boom in sandbox\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('boom in sandbox')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => {
const ctx = await setup()
const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' })
const primitive = await call(ctx, 'cordis_try', { code: 'throw \'plain-string-throw\'' })
expect(primitive.isError).toBe(true)
expect(text(primitive)).toContain('plain-string-throw')
const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' })
const nullish = await call(ctx, 'cordis_try', { code: 'throw null' })
expect(nullish.isError).toBe(true)
})
it('rejects code that does not return a plugin', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'return 42' })
const result = await call(ctx, 'cordis_try', { code: 'return 42' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('must `return` a plugin')
expect(text(result)).toContain('must `return` a Plugin')
})
it('answers a missing return with the two valid plugin forms', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' })
const result = await call(ctx, 'cordis_try', { code: 'const plugin = (ctx) => {}' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('did you forget `return`?')
})
it('disposes a plugin whose apply throws, and reports the error', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }',
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('apply exploded')
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'usurper',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'cordis_mount',
name: 'cordis_try',
description: 'dup',
parameters: {},
${CONTENT_OUTPUT_CODE}
@@ -734,15 +733,15 @@ describe('cordis_mount', () => {
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('already registered')
expect(text(result)).toContain('first cordis_unmount')
// The original cordis_mount still dispatches — the failed fiber is gone.
const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
expect(text(result)).toContain('first cordis_stop')
// The original cordis_try still dispatches — the failed fiber is gone.
const retry = await call(ctx, 'cordis_try', { code: LISTENER_CODE })
expect(retry.isError).toBe(false)
})
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
globalThis.__cordis_tool_leak = 'leaked'
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
@@ -754,22 +753,22 @@ describe('cordis_mount', () => {
})
it.each([
['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'],
['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'],
['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'],
['require(\'fs\')', 'require is not available in the temporary Plugin sandbox', 'inject: [\'fs\']'],
['setTimeout(() => {}, 5)', 'setTimeout is not available in the temporary Plugin sandbox', 'ctx.setTimeout'],
['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'],
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` })
const result = await call(ctx, 'cordis_try', { code: `${invocation}\nreturn (ctx) => {}` })
expect(result.isError).toBe(true)
expect(text(result)).toContain(trapMessage)
expect(text(result)).toContain(redirect)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'ticker',
@@ -781,7 +780,7 @@ describe('cordis_mount', () => {
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: active')
expect(text(result)).toContain('is running')
await new Promise(resolve => setTimeout(resolve, 50))
expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick')
})
@@ -790,7 +789,7 @@ describe('cordis_mount', () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
console.warn('warned')
console.error('errored')
@@ -808,7 +807,7 @@ describe('cordis_mount', () => {
it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'return { name: \'ts\' as const, apply(ctx) {} }',
})
expect(result.isError).toBe(true)
@@ -820,7 +819,7 @@ describe('cordis_mount', () => {
// The canonical model mistake: closing the returned object with `});` as
// if it were a callback argument. The word "as" in a STRING elsewhere must
// not trigger the TypeScript hint — the heuristic reads the failing line.
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});',
})
expect(result.isError).toBe(true)
@@ -843,7 +842,7 @@ describe('cordis_mount', () => {
it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' })
const result = await call(ctx, 'cordis_try', { code: 'throw new SyntaxError(\'user-crafted\')' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('failed to parse')
expect(text(result)).toContain('user-crafted')
@@ -851,10 +850,10 @@ describe('cordis_mount', () => {
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
const ctx = await setup({ vmTimeoutMs: 50 })
const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' })
const result = await call(ctx, 'cordis_try', { code: 'while (true) {}' })
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/timed? ?out/i)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
@@ -862,7 +861,7 @@ describe('cordis_mount', () => {
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
// false.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'probe-instanceof',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts'
import { presentInspectCall, presentTryCall, presentStopCall } from '../src/present.ts'
import { setup } from './helpers.ts'
/**
@@ -18,17 +18,17 @@ describe('presenters', () => {
})
})
it('cordis_mount renders a generic execute card carrying the code as raw input', () => {
expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({
it('cordis_try renders a generic execute card carrying the code as raw input', () => {
expect(presentTryCall({ code: 'return (ctx) => {}' })).toEqual({
card: 'generic',
kind: 'execute',
title: 'Mount plugin into live cordis runtime',
title: 'Try temporary Cordis Plugin',
rawInput: { code: 'return (ctx) => {}' },
})
})
it('cordis_unmount renders a generic delete card titled with the id', () => {
expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' })
it('cordis_stop renders a generic delete card titled with the id', () => {
expect(presentStopCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Stop temporary Cordis Plugin dyn-1' })
})
it('is wired onto the registered definitions through the defineTool soft-validation path', async () => {
@@ -41,9 +41,9 @@ describe('presenters', () => {
expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({
title: 'Inspect cordis runtime: api: tools',
})
expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' })
expect(ctx.tools.get('cordis_try')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' })
expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Stop temporary Cordis Plugin dyn-2' })
// Soft validation: presenter args that fail the schema render as no card, never a throw.
expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined()
expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 42 })).toBeUndefined()
})
})

View File

@@ -10,7 +10,7 @@ import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts'
/** Mount a plugin whose `apply` touches one framework member, and report the error text. */
async function mountTouching(ctx: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`,
})
expect(result.isError).toBe(true)
@@ -41,7 +41,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'root-bypass',
@@ -66,7 +66,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('rejects assignment to the façade rather than silently dropping it', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }',
})
expect(result.isError).toBe(true)
@@ -78,7 +78,7 @@ describe('sandbox context façade — escape surface is closed', () => {
// `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return
// guards reject that Context before the registration lands.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'svc-ctx-escape',
@@ -108,7 +108,7 @@ describe('sandbox context façade — escape surface is closed', () => {
name: 'host-async-svc',
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
})
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'async-consumer',
@@ -135,7 +135,7 @@ describe('sandbox context façade — escape surface is closed', () => {
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'introspector',
@@ -157,7 +157,7 @@ describe('sandbox context façade — inject gate on services', () => {
// mount does not declare it — reaching it would let the mount depend on a
// provider cordis does not know about, so it is refused.
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }',
})
expect(result.isError).toBe(true)
@@ -167,7 +167,7 @@ describe('sandbox context façade — inject gate on services', () => {
it('denies an undeclared live service reached through ctx.get too', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }',
})
expect(result.isError).toBe(true)
@@ -176,7 +176,7 @@ describe('sandbox context façade — inject gate on services', () => {
it('allows a service the mount DID declare in inject', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
const result = await call(ctx, 'cordis_try', {
code: `
return {
name: 'declared',
@@ -186,17 +186,17 @@ describe('sandbox context façade — inject gate on services', () => {
`,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('state: active')
expect(text(result)).toContain('is running')
})
it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
// Without declared inject, Cordis cannot park the consumer when its provider unmounts. The
// façade refuses access up front instead of leaving a zombie tool.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }',
})
const undeclared = await call(ctx, 'cordis_mount', {
const undeclared = await call(ctx, 'cordis_try', {
code: `
return {
name: 'sloppy-consumer',
@@ -229,7 +229,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
// returns the same name/description/parameters view as schemas(), with no execute.
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'reporter',
@@ -241,7 +241,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
parameters: {},
${CONTENT_OUTPUT_CODE}
async execute() {
const view = ctx.tools.get('cordis_mount')
const view = ctx.tools.get('cordis_try')
return [{ type: 'text', text: JSON.stringify({
hasExecute: 'execute' in view,
hasPresentCall: 'presentCall' in view,
@@ -259,13 +259,13 @@ describe('sandbox tools façade — get is a read-only schema view', () => {
const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
expect(shape.hasExecute).toBe(false)
expect(shape.hasPresentCall).toBe(false)
expect(shape.name).toBe('cordis_mount')
expect(shape.name).toBe('cordis_try')
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
})
it('ctx.tools.get returns undefined for an unknown tool', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', {
await call(ctx, 'cordis_try', {
code: `
return {
name: 'unknown-probe',

View File

@@ -30,10 +30,11 @@ describe('tool registration', () => {
it('registers the three cordis tools with the documented schemas', async () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_try', 'cordis_stop']))
expect(names).not.toEqual(expect.arrayContaining(['cordis_mount', 'cordis_unmount']))
const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')!
const props = (inspect.parameters as { properties: Record<string, { enum?: string[]; type?: string }> }).properties
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events'])
expect(props.name?.type).toBe('string')
})
})

View File

@@ -6,7 +6,7 @@ import * as tool from '../src/index.ts'
import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts'
/**
* Disposal semantics: `cordis_unmount` reaches quiescence before returning,
* Disposal semantics: `cordis_stop` reaches quiescence before returning,
* and disposing the tool-cordis fiber itself (the HMR path) cascades over the
* whole dynamic subtree through the ordinary parent→child fiber lifecycle.
*/
@@ -15,46 +15,46 @@ afterEach(() => {
vi.restoreAllMocks()
})
describe('cordis_unmount', () => {
describe('cordis_stop', () => {
it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => {
const ctx = await setup()
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
ctx.tools.register(dummyTool('trigger_before'))
expect(log).toHaveBeenCalledTimes(1)
const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
const result = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected cordis_unmount success')
if (result.isError) throw new Error('expected cordis_stop success')
expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' })
expect(text(result)).toContain('unmounted dyn-1')
expect(text(result)).toBe('Temporary Plugin dyn-1 was stopped and removed.')
// Immediately after the awaited unmount, the listener must be gone — no
// grace period, no eventual consistency.
ctx.tools.register(dummyTool('trigger_after'))
expect(log).toHaveBeenCalledTimes(1)
expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)')
expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.')
})
it('unregisters a self-made tool on unmount', async () => {
const ctx = await setup()
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
expect(ctx.tools.get('reverse_text')).toBeDefined()
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
await call(ctx, 'cordis_stop', { id: 'dyn-1' })
expect(ctx.tools.get('reverse_text')).toBeUndefined()
})
it('rejects an unknown id, and a second unmount of the same id', async () => {
const ctx = await setup()
const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' })
const unknown = await call(ctx, 'cordis_stop', { id: 'dyn-99' })
expect(unknown.isError).toBe(true)
expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"')
expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"')
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' })
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
await call(ctx, 'cordis_stop', { id: 'dyn-1' })
const again = await call(ctx, 'cordis_stop', { id: 'dyn-1' })
expect(again.isError).toBe(true)
})
})
@@ -67,8 +67,8 @@ describe('HMR safety', () => {
const fiber = await ctx.plugin(tool)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await call(ctx, 'cordis_mount', { code: LISTENER_CODE })
await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE })
await call(ctx, 'cordis_try', { code: LISTENER_CODE })
await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE })
expect(ctx.tools.get('reverse_text')).toBeDefined()
await fiber.dispose()
@@ -76,7 +76,7 @@ describe('HMR safety', () => {
// The whole subtree is gone: the self-made tool, the cordis tools, and the
// mounted listener (no log on a fresh tools/change).
expect(ctx.tools.get('reverse_text')).toBeUndefined()
expect(ctx.tools.get('cordis_mount')).toBeUndefined()
expect(ctx.tools.get('cordis_try')).toBeUndefined()
const calls = log.mock.calls.length
ctx.tools.register(dummyTool('trigger_post_dispose'))
expect(log).toHaveBeenCalledTimes(calls)

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_stop', 'cordis_try', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -18,10 +18,10 @@ buffer
5| <blank>
6| "▌ "
style 0-0 fg=yellow
7| "▌ ◌ Mount plugin into live cordis runtime "
7| "▌ ◌ Try temporary Cordis Plugin "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-40 bold
style 3-30 bold
8| "▌ { "
style 0-0 fg=yellow
9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
@@ -33,10 +33,10 @@ buffer
12| "▌ "
style 0-0 fg=yellow
13| <blank>
14| "▌ ◌ Unmount dyn-1 "
14| "▌ ◌ Stop temporary Cordis Plugin dyn-1 "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-16 bold
style 3-37 bold
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "

View File

@@ -396,16 +396,16 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins cordis inspect, dynamic mount, and unmount cards with production presenters', async () => {
it('pins cordis inspect, try, and stop cards with production presenters', async () => {
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
const calls = [
{ id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } },
{
id: 'cordis-2',
name: 'cordis_mount',
name: 'cordis_try',
arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" },
},
{ id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } },
{ id: 'cordis-3', name: 'cordis_stop', arguments: { id: 'dyn-1' } },
]
await renderAfter(harness, () => { appendToolCalls(harness.session, calls) })
await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true })