Merge commit '70396085b141370ce32de1be4e225b4384eaf46d' into HEAD
# Conflicts: # .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md # .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml # docs/config-catalog.i18n.yaml # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/tool-catalog.i18n.yaml # docs/tool-catalog.md # docs/tool-catalog.zh.md # examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json # examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json # packages/core/tools/README.i18n.yaml # packages/core/tools/README.zh.md # packages/core/tools/src/code-mode.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/plugin-inventory/tests/inventory.spec.ts # packages/mcp/mcp-client/tests/mcp-client.e2e.ts # packages/mcp/mcp-client/tests/mcp-client.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # packages/test-support/acp-snapshot/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
6
packages/extensions/tool-cordis/README.i18n.yaml
Normal file
6
packages/extensions/tool-cordis/README.i18n.yaml
Normal file
@@ -0,0 +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 packages/extensions/tool-cordis/README.md
|
||||
README.md: 396a844014328da8cfad57fa7728793b91a9aff7
|
||||
README.zh.md: b38a4b518a28f9af76ab22325606b8faa772c1c1
|
||||
104
packages/extensions/tool-cordis/README.md
Normal file
104
packages/extensions/tool-cordis/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The self-referential Cordis toolset: five model-facing tools over the live runtime in the current DSH process. The registry, the vm sandbox, and the browser broadcast belong to [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md) (`ctx.dynamic`), which this toolset injects — a composition with these tools but no runner never activates them. Design home — sandbox semantics, dynamic-package lifecycle and composition, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## What it does
|
||||
|
||||
Two paired verbs, plus the read-only report.
|
||||
|
||||
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, this session's dynamic packages, the reflection-backed `api` / `events` references, and the compile-time `client` slot surface a browser half can contribute UI into. An exact `name` with `what: "api"`, `what: "events"`, or `what: "client"` narrows the report and adds the full contract.
|
||||
- `cordis_define` — records a package (`name`, `purpose`, and a host half `code` and/or a browser half `client`) after syntax-checking both halves. Nothing runs; the user sees a card for it in the conversation with a start control. The minted `dyn-<n>` id rides the result value AND the durable presentation metadata, which is how that card addresses the run verbs on replay.
|
||||
- `cordis_run` — evaluates the host half in the sandbox and delivers the browser half to every open web page. Running an already-running package re-delivers the live version instead of failing, which is how a reloaded page gets it back.
|
||||
- `cordis_stop` — disposes the host half to quiescence and withdraws the browser half; the definition survives and can run again.
|
||||
- `cordis_undefine` — stops the package if needed and forgets the definition; its card stays in the conversation as an unloaded record.
|
||||
|
||||
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
|
||||
|
||||
Dynamic packages 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`/`cordis_undefine`, 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. Every verb is session-scoped: a package is visible and controllable only in the session that defined it.
|
||||
|
||||
## Trust stance
|
||||
|
||||
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded; records with JSON-invisible keys and subclassed or decorated schema arrays reject before normalization. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## Config
|
||||
|
||||
None. The vm evaluation bound (`vmTimeoutMs`) and the browser acknowledgement window (`ackTimeoutMs`) belong to the runner service that owns the sandbox and the broadcast — see [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md#config).
|
||||
|
||||
## The generated client slot catalog
|
||||
|
||||
`src/client-catalog.ts` describes the browser half's seats, generated by `scripts/gen-client-catalog.ts` (freshness-gated by `pnpm run verify-client-catalog` in `doc-sync`) from a lexical scan of every `SlotMap` declaration merge and every `slots.register` call site. It carries the one surface a browser half can act on — the slot keys, each register call's options, the props a component receives, who already occupies the seat, and which owner's mount makes the seat exist — as plain data: this package stays host-side and imports no client module, so the strings are the only thing that crosses. The generator fails loud rather than shipping an entry a model cannot act on: a slot with no registrant-facing prose, a non-literal `kind`/`scope`, owner props no export provides, a duplicate key, or a registration into an undeclared slot all break the gate. Owner props expand one level — the owner declaration with its own member documentation, and the names of the shapes its fields reference — and one slot's whole report is budget-capped, because narrowing to a single slot exists to spend less context, not more.
|
||||
|
||||
A slot's teaching text is its declaration's JSDoc, so improving what the model reads means editing the contract at its declaring package — not this catalog.
|
||||
|
||||
## Where the API report comes from
|
||||
|
||||
`cordis_inspect what:"api"` / `what:"events"` renders `src/api-catalog.ts`, the generated projection of the workspace's Cordis declarations: rendered method signatures, source JSDoc, harness events with their dispatch modes, and the type shapes those signatures reference, all produced by the same AST walk as `docs/subsystems`, so the data a model reads and the rendered docs cannot diverge. It is a compile-time fact about the REPOSITORY, so `pnpm run gen-cordis-api` regenerates it and `pnpm run verify-cordis-api` gates its freshness.
|
||||
|
||||
`src/inspect.ts` intersects that catalog with the LIVE service store: what is RUNNING comes from the store, what each service CAN DO comes from the catalog, and a live service the catalog does not cover is reported as reachable with no signatures rather than omitted. A package that needs the list in its own code copies it out of a report — the catalog is a compile-time fact about the repository, so a copied list and a freshly read one say the same thing for any one deployment.
|
||||
|
||||
Two model-facing judgements live in this package rather than in the artifacts, because reflection data is faithful to the code while a report has to be useful:
|
||||
|
||||
- **Only callable methods are shown.** Non-method members are state rather than a verb, and their rendered form carries initializers from the implementation body; symbol-keyed members are internal seams between plugins that a package façade deliberately cannot reach, so naming one would advertise a call that cannot be made.
|
||||
- **Only keys a host half can reach are named to a model.** The reflection model covers every `ctx.<key>` a package declares, including launcher-supplied boot values (`agent`, `headlessIo`, …) and browser-half services (`connection`). `src/curation.ts` classifies each one's `reach` — `injectable`, `not-a-service`, or `other-face` — and only `injectable` keys reach a report: naming a key a package cannot reach advertises a call that cannot be made. The classification is carried as data on each catalog entry rather than applied while rendering, so the exclusion is testable on its own, and `verify-cordis-catalog` pins the classified set to exactly the keys the documentation projection does not render — a newly declared key stops the gate instead of quietly inviting a model to `inject` something that will never arrive. A classified key that nonetheless has a live provider is still reported as running and injectable: the service store is the authority on what exists.
|
||||
|
||||
The generated `INHERITED_CTX_API` closes the `api` report with the framework-inherited `ctx` surface (`ctx.on`, `ctx.effect`, `ctx.loader`, the timer helpers): those members are the Context itself rather than service keys, and the framework tier lives in pinned vendor packages outside every analyzed face, so the generator curates that one tier and renders it into both this catalog and `docs/cordis-api/inherited.md`. A live service the catalog does not describe is reported as running and still injectable rather than as absent. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
|
||||
## Rendering
|
||||
|
||||
Every tool renders a `generic` card (`read` / `execute` / `delete`); `cordis_define` carries the submitted halves as `rawInput` and titles the card with the label and purpose. Presenters are pure functions of the args, and results keep the default text rendering. A Web client registers its own keyed `cordis_define` row (`@deepseek-ai/dsh-client-ui-cordis`) and reads the label, purpose, and minted id from the call arguments and the result metadata; the generic card is what a surface without that registration falls back to.
|
||||
|
||||
## Export shape
|
||||
|
||||
Namespace plugin: named exports `name` / `inject` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). It injects `tools` and `dynamicCordisRunner`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The conversation model sees the generated [`cordis_inspect`, `cordis_define`, `cordis_run`, `cordis_stop`, and `cordis_undefine` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request in that tool view.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
#### 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; `what: "temporary"` uses the `## Dynamic Packages` heading. Each row reports the id, label, purpose, which halves exist, run state and revision, provided and awaited services, registered host methods, and the last browser-half load report. The empty state explains that definitions live only in this process's memory. Broad API/event reports omit JSDoc; `name` with `what: "api"`, `what: "events"`, or `what: "client"` returns one exact target with its full contract. The `client` section lists one seat per line with its cardinality, scope, summary, and whether registering there replaces shipped UI, then the cross-cutting registrant rules; the per-seat register options, owner and framework props, and runnable example arrive only under an exact `name`. Define answers that the package is defined and NOT running yet with the id to run; run reports the revision, what the host half provides or waits for, and whether a page acknowledged the browser half; stop and undefine acknowledge in one line. Every refusal is a tool error carrying the runner's teaching text. The submitted program remains in assistant tool-call history.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Inspect output and submitted package code are data-dependent and resent until compaction; lifecycle acknowledgements are small. The `client` section is bounded by the shipped slot count (two lines each) and its per-seat detail is opt-in, so the default report grows with the slot surface rather than with its documentation.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Later requests after cordis_run
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A running package may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` and `cordis_undefine` remove those contributions after quiescence.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Indirect token impact equals the running package's contributions and lasts only for its process-local lifetime.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Running or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged running set remains prefix-stable.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so package code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance).
|
||||
- **The `ctx` façade exposes no `effect()`** — package code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths.
|
||||
- **The vm and acknowledgement bounds belong to the runner** — see its [Known Limitations](../cordis-host-runner/README.md#known-limitations-and-deferred-work); an async host-half body escapes `vmTimeoutMs`.
|
||||
104
packages/extensions/tool-cordis/README.zh.md
Normal file
104
packages/extensions/tool-cordis/README.zh.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
自引用 Cordis 工具集:五个面向模型的工具,操作当前 DSH 进程中的实时运行时。注册表、vm 沙箱与浏览器广播属于 [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md)(`ctx.dynamic`),本工具集注入它——只装这些工具而不装 runner 的组合永远不会激活它们。沙箱语义、动态包生命周期与组合及既定决策详见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
两组配对动词,外加只读报告。
|
||||
|
||||
- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活插件 fiber、已注册工具、本会话的动态包、反射支持的 `api`/`events` 参考,以及浏览器半可以向其贡献 UI 的编译期 `client` 槽面。精确的 `name` 配合 `what: "api"`、`what: "events"` 或 `what: "client"` 可缩窄报告,并附上完整约定。
|
||||
- `cordis_define`:在语法预检两个半之后登记一个包(`name`、`purpose`,以及 host 半 `code` 和/或浏览器半 `client`)。此时不运行任何东西;用户会在会话里看到它的卡片和一个启动控件。铸出的 `dyn-<n>` 标识同时进入结果 value **与**持久的呈现元数据,卡片正是靠后者在 replay 中寻址运行动词。
|
||||
- `cordis_run`:在沙箱中求值 host 半,并把浏览器半投递给每个打开的网页。对已在运行的包再次运行不会失败,而是重新投递当前版本——这正是被刷新过的页面把包取回来的方式。
|
||||
- `cordis_stop`:把 host 半 dispose 到完全停稳,并从各页面撤回浏览器半;定义存续,可以再次运行。
|
||||
- `cordis_undefine`:必要时先停止该包,再忘掉定义;它的卡片作为一条已卸载记录留在会话里。
|
||||
|
||||
面向模型的确切 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
|
||||
|
||||
动态包只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_stop`/`cordis_undefine`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现普通的本地、项目或仓库插件。每个动词都以会话为界:一个包只在定义它的那个会话里可见、可控。
|
||||
|
||||
## 信任立场
|
||||
|
||||
该沙箱隔离全局变量,但不是安全边界。Node 全局变量不存在,或会重定向到 `ctx.fs`、`ctx.web`、`ctx.bash` 等 Cordis 服务;写入 `globalThis` 的内容保持局部,但 host realm helper 使逃逸成为可能。运行中的 host 半收到不含框架内部机制的 façade,但获准服务仍会影响存活运行时。动态工具 schema 与 annotation 通过迭代式 JSON 克隆和 schema 规范化跨越 realm,因此有效的深层声明受内存而非调用栈限制;含 JSON 不可见 key 的 record,以及子类化或装饰过的 schema array,会在规范化前被拒绝。应当像对待 bash 访问一样对待该工具集;参见[设计与信任立场](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
无。vm 求值边界(`vmTimeoutMs`)与浏览器确认窗口(`ackTimeoutMs`)属于拥有沙箱与广播的 runner 服务——见 [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md#config)。
|
||||
|
||||
## 生成的 client 槽目录
|
||||
|
||||
`src/client-catalog.ts` 描述浏览器半的座位,由 `scripts/gen-client-catalog.ts` 生成(新鲜度门禁为 `doc-sync` 中的 `pnpm run verify-client-catalog`),数据来自对每一处 `SlotMap` 声明合并与每一个 `slots.register` 调用点的词法扫描。它承载浏览器半唯一能动的那个面——槽键、每个 register 调用的选项、组件会收到的 props、谁已经占着这个座位、以及哪个 owner 挂着这个座位才存在——并且只以纯数据承载:本包始终在 host 侧、不 import 任何 client 模块,跨越两平面的只有这些字符串。生成器宁可高声失败也不吐出一条模型无法照做的条目:槽缺少面向 registrant 的 JSDoc 正文、`kind`/`scope` 不是字面量、owner props 没有任何导出声明、键重复、或注册进了没人声明的槽,都会让门禁变红。owner props 只展开一层——owner 声明本身连它的成员文档,加上其字段所引用的那些形状的名字——而单个槽的整份报告有行数上限:收窄到一个槽的意义是少花上下文,不是多花。
|
||||
|
||||
一个槽的教学文案就是它声明处的 JSDoc,所以要改模型读到的内容,改的是声明它的那个包里的约定,而不是这份目录。
|
||||
|
||||
## API 报告从哪里来
|
||||
|
||||
`cordis_inspect what:"api"`/`what:"events"` 渲染的是 `src/api-catalog.ts`,即工作区 Cordis 声明的生成投影:渲染好的方法签名、源码 JSDoc、带分发模式的 harness 事件,以及这些签名引用到的类型形状——全部由与 `docs/subsystems` 同一次 AST 遍历产出,因此模型读到的数据与渲染出的文档不可能彼此偏离。它是关于**仓库**的编译期事实,所以用 `pnpm run gen-cordis-api` 重新生成、用 `pnpm run verify-cordis-api` 守它的新鲜度。
|
||||
|
||||
`src/inspect.ts` 把这份目录与**活的**服务存储取交集:**谁在跑**由存储回答,**每个服务能做什么**由目录回答;目录没覆盖到的活服务会被报成可达但没有签名,而不是被省略。包代码若要在自己源码里用这份清单,就从报告里抄出来——目录是关于仓库的编译期事实,所以对任一个部署而言,抄出来的清单与现读的清单说的是同一件事。
|
||||
|
||||
有两项面向模型的判断住在本包里,而不住在产物里,因为反射数据忠于代码,而报告必须有用:
|
||||
|
||||
- **只展示可调用的方法。** 非方法成员是状态而不是动词,而它们渲染出来的形式会带上实现体里的初始值;以 symbol 为键的成员是插件之间的内部 seam,包的 façade 刻意无法触达,所以点出其中任何一个,都等于宣传一次根本发不出的调用。
|
||||
- **只有 host 半够得到的键,才会被点名给模型。** 反射模型覆盖包声明的每一个 `ctx.<key>`,其中包括 launcher 提供的 boot 值(`agent`、`headlessIo` 等)与浏览器半的服务(`connection`)。`src/curation.ts` 会为每一个这样的键归类它的 `reach`——`injectable`、`not-a-service` 或 `other-face`——而只有 `injectable` 的键能进报告:点名一个包够不到的键,就等于宣传一次根本发不出的调用。这份归类是作为每条目录条目上的数据携带的,而不是在渲染时才施加,因此这项排除可以单独测试;同时 `verify-cordis-catalog` 把被归类的集合钉成「文档投影不渲染的键」这个集合本身——新声明一个键会把门禁拦下来,而不是悄悄引诱模型去 `inject` 一个永远不会到来的东西。一个被归类、但确实有存活提供方的键,仍然会被报成在跑且可 inject:服务 store 才是「什么存在」的权威。
|
||||
|
||||
生成常量 `INHERITED_CTX_API` 为 `api` 报告收尾,列出框架继承来的 `ctx` 面(`ctx.on`、`ctx.effect`、`ctx.loader`、各 timer 辅助方法):这些成员本身就是 Context,不是某个服务键;而框架层住在 pinned vendor 包里,位于每一个被分析的契约面之外——所以生成器策展这**一层**,并把它同时渲染进本目录与 `docs/cordis-api/inherited.md`。一个活着、但目录并不描述的服务,会被报成“在跑、且仍可 inject”,而不是报成不存在。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。
|
||||
|
||||
## 渲染
|
||||
|
||||
每个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_define` 以 `rawInput` 携带提交的两个半,并用标签与用途作为卡片标题。presenter 是 args 的纯函数,结果保留默认文本渲染。Web 客户端注册自己的 keyed `cordis_define` 行(`@deepseek-ai/dsh-client-ui-cordis`),从调用参数与结果元数据里取标签、用途和铸出的标识;没有该注册的界面则退回到这张 generic 卡片。
|
||||
|
||||
## 导出形式
|
||||
|
||||
Namespace 插件:命名导出 `name`/`inject`/`apply`,无默认导出([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。它注入 `tools` 与 `dynamicCordisRunner`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_define`、`cordis_run`、`cordis_stop` 和 `cordis_undefine` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该工具视图中的每次请求承担固定 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要该工具视图不变,前缀就保持稳定。隐藏这些定义的 scope 或插件生命周期变更,可能使从第一个变化的 schema token 起的复用失效。
|
||||
|
||||
### 工具调用历史与结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
检查会精确地用 `## <section>` 加换行及取决于数据的正文来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Dynamic Packages` 标题。每一行都会报告标识、标签、用途、存在哪些半、运行状态与版本号、提供和等待的服务、已注册的 host 方法,以及最后一次浏览器半装载上报;空状态说明定义只存在于本进程内存中。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"`、`what: "events"` 或 `what: "client"` 返回一个精确目标及其完整约定。`client` 区段每个座位一行,给出其基数、作用域、摘要,以及注册进去是否会替换出厂 UI,随后是跨座位通用的 registrant 纪律;每个座位的 register 选项、owner 与框架 props、可直接运行的示例,只在精确 `name` 时才吐出。define 回答该包已定义、尚未运行,并给出用于运行的标识;run 报告版本号、host 半提供或等待什么,以及是否有页面确认了浏览器半;stop 与 undefine 各以一行确认。每一次拒绝都是携带 runner 教学文案的工具错误。提交的程序保留在 assistant 工具调用历史中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
检查输出与提交的包代码取决于数据,并在压缩(compaction)前重复发送;生命周期确认文本很短。`client` 区段的体量由出厂槽数量决定(每座位两行),每座位细节按需索取,因此默认报告随槽面增长,而不是随其文档量增长。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
### cordis_run 后的后续请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
运行中的包可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop` 与 `cordis_undefine` 会在完全停稳后移除这些贡献。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
间接 token 影响等于运行中包的贡献,且只在其进程内生命周期内持续。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
运行或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;运行集合不变时,前缀保持稳定。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此包代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。
|
||||
- **`ctx` façade 不公开 `effect()`**:包代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。
|
||||
- **vm 与确认窗口这两个边界属于 runner**:见它的[已知限制](../cordis-host-runner/README.md#known-limitations-and-deferred-work);async 的 host 半主体可逃出 `vmTimeoutMs`。
|
||||
57
packages/extensions/tool-cordis/package.json
Normal file
57
packages/extensions/tool-cordis/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-cordis",
|
||||
"description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins",
|
||||
"version": "0.1.0-rc.6",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/tool-cordis"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
4757
packages/extensions/tool-cordis/src/api-catalog.ts
Normal file
4757
packages/extensions/tool-cordis/src/api-catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
packages/extensions/tool-cordis/src/fiber-state.ts
Normal file
31
packages/extensions/tool-cordis/src/fiber-state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Runtime mirror and labels for Cordis's `FiberState` const enum. A const enum has no runtime
|
||||
* object to import, so these values mirror the pinned vendored definition while retaining its
|
||||
* type.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
|
||||
*/
|
||||
|
||||
import type { FiberState as FiberStateEnum } from '@deepseek-ai/cordis'
|
||||
|
||||
/** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */
|
||||
export const FiberState = {
|
||||
PENDING: 0 as FiberStateEnum.PENDING,
|
||||
LOADING: 1 as FiberStateEnum.LOADING,
|
||||
ACTIVE: 2 as FiberStateEnum.ACTIVE,
|
||||
FAILED: 3 as FiberStateEnum.FAILED,
|
||||
DISPOSED: 4 as FiberStateEnum.DISPOSED,
|
||||
UNLOADING: 5 as FiberStateEnum.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** The cordis `FiberState` enum type, re-exported so mirror consumers need one import. */
|
||||
export type FiberState = FiberStateEnum
|
||||
|
||||
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS = {
|
||||
[FiberState.PENDING]: 'pending',
|
||||
[FiberState.LOADING]: 'loading',
|
||||
[FiberState.ACTIVE]: 'active',
|
||||
[FiberState.FAILED]: 'failed',
|
||||
[FiberState.DISPOSED]: 'disposed',
|
||||
[FiberState.UNLOADING]: 'unloading',
|
||||
} as const satisfies Record<FiberState, string>
|
||||
530
packages/extensions/tool-cordis/src/index.ts
Normal file
530
packages/extensions/tool-cordis/src/index.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Model-facing Cordis runtime/package inspection, define, run, stop, and remove tools.
|
||||
* @module @deepseek-ai/dsh-tool-cordis
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId,
|
||||
} from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { DynamicCordisReference } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { missingServices, providedServices } from './inspect.ts'
|
||||
import {
|
||||
presentDefineCall, presentInspectListCall, presentInspectQueryCall, presentInspectSelfCall, presentRunCall,
|
||||
presentStopCall, presentUndefineCall,
|
||||
} from './present.ts'
|
||||
import { CORDIS_SYSTEM_PROMPT } from './prompt.ts'
|
||||
import { hostInspectProviders } from './providers.ts'
|
||||
|
||||
export const name = 'tool-cordis'
|
||||
export const inject = ['tools', 'systemPrompt', 'dynamicCordisRunner', 'cordisInspect']
|
||||
|
||||
function requireAgent(exec: ToolExecution): Agent {
|
||||
if (exec.agent === undefined) throw new Error('Cordis dynamic tools require an Agent-backed session')
|
||||
return exec.agent
|
||||
}
|
||||
|
||||
/** Register the Cordis tools and explicit `@pluginId` context injection. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({ name: 'tool:cordis', order: 115, text: CORDIS_SYSTEM_PROMPT })
|
||||
for (const provider of hostInspectProviders(ctx)) {
|
||||
ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`)
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_list',
|
||||
description:
|
||||
'List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest '
|
||||
+ 'manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and '
|
||||
+ 'input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and '
|
||||
+ 'method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business '
|
||||
+ 'Service that Plugin code can call.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
execute(_args, _exec): Promise<JsonValue> {
|
||||
return Promise.resolve({ providers: ctx.cordisInspect.list() } as unknown as JsonValue)
|
||||
},
|
||||
presentCall: presentInspectListCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_query',
|
||||
description:
|
||||
'Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come '
|
||||
+ 'from cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before cordis_define '
|
||||
+ 'to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot '
|
||||
+ 'trees and props. Host queries run locally. A Client query waits for the first valid page response and '
|
||||
+ 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service '
|
||||
+ 'methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate '
|
||||
+ 'the compact signature directory, then query the exact service or event for its structured contract and '
|
||||
+ 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the '
|
||||
+ 'exact root for its complete registration contract and props.',
|
||||
parameters: {
|
||||
platform: { type: 'string', required: true, enum: ['host', 'client'], description: 'Runtime platform that owns the Provider.' },
|
||||
provider: { type: 'string', required: true, description: 'Exact Provider ID returned by cordis_inspect_list.' },
|
||||
method: { type: 'string', required: true, description: 'Exact method name declared by the Provider manifest.' },
|
||||
input: { type: 'json', description: 'Optional query input; it must satisfy the method input schema.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const data = await ctx.cordisInspect.query(
|
||||
args.platform,
|
||||
args.provider,
|
||||
args.method,
|
||||
args.input,
|
||||
requireAgent(exec),
|
||||
exec.signal,
|
||||
)
|
||||
return { platform: args.platform, provider: args.provider, method: args.method, data }
|
||||
},
|
||||
presentCall: presentInspectQueryCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_self',
|
||||
description:
|
||||
'Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, '
|
||||
+ 'list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package '
|
||||
+ 'summary. Only pluginId plus packageId returns that immutable Package\'s Host/Client source and runtime '
|
||||
+ 'diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing '
|
||||
+ 'an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code '
|
||||
+ 'nor changes version pointers.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', description: 'Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin.' },
|
||||
packageId: { type: 'string', description: 'Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
execute(args, exec): Promise<JsonValue> {
|
||||
const agent = requireAgent(exec)
|
||||
if (args.packageId !== undefined && args.pluginId === undefined) {
|
||||
throw new Error('cordis_inspect_self packageId requires pluginId')
|
||||
}
|
||||
if (args.pluginId === undefined) {
|
||||
return Promise.resolve({
|
||||
mode: 'plugins',
|
||||
plugins: ctx.dynamicCordisRunner.listPlugins(agent).map(reference => selfSummary(reference)),
|
||||
} as unknown as JsonValue)
|
||||
}
|
||||
const pluginId = CordisDynamicPluginId(args.pluginId)
|
||||
if (args.packageId === undefined) {
|
||||
const plugin = ctx.dynamicCordisRunner.inspectPlugin(agent, pluginId)
|
||||
return Promise.resolve({
|
||||
mode: 'plugin',
|
||||
...selfSummary(plugin),
|
||||
packages: plugin.packages.map(pkg => ({
|
||||
...pkg,
|
||||
packageId: String(pkg.packageId),
|
||||
isCurrent: pkg.packageId === plugin.currentPackageId,
|
||||
isNext: pkg.packageId === plugin.nextPackageId,
|
||||
})),
|
||||
} as unknown as JsonValue)
|
||||
}
|
||||
return Promise.resolve(inspectSelfPackage(
|
||||
ctx,
|
||||
agent,
|
||||
pluginId,
|
||||
CordisDynamicPackageId(args.packageId),
|
||||
) as unknown as JsonValue)
|
||||
},
|
||||
presentCall: presentInspectSelfCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_define',
|
||||
description:
|
||||
'Define an immutable Cordis Package. For a new Plugin, use kind:"new" and provide only a semantic prefix of '
|
||||
+ '3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing '
|
||||
+ 'Plugin, use kind:"existing" with its exact pluginId to append a Package without overwriting older versions. '
|
||||
+ 'Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns '
|
||||
+ 'a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a '
|
||||
+ 'Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it '
|
||||
+ 'does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the '
|
||||
+ 'returned IDs.',
|
||||
parameters: {
|
||||
plugin: {
|
||||
required: true,
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', const: 'new', required: true },
|
||||
idPrefix: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', const: 'existing', required: true },
|
||||
pluginId: { type: 'string', required: true, description: 'Exact ID of an existing Plugin; the new Package is appended to that instance.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
name: { type: 'string', required: true, description: 'Short, readable Package name.' },
|
||||
purpose: { type: 'string', required: true, description: 'One-sentence, user-facing description of the Package purpose.' },
|
||||
code: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
host: { type: 'string', description: 'Plain JavaScript function body that returns the Host-half Cordis Plugin.' },
|
||||
client: { type: 'string', description: 'Plain JavaScript function body that returns the browser Client-half Cordis Plugin.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
pluginId: { type: 'string', required: true },
|
||||
packageId: { type: 'string', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
purpose: { type: 'string', required: true },
|
||||
hasHostHalf: { type: 'boolean', required: true },
|
||||
hasClientHalf: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `Defined ${value.pluginId}/${value.packageId} (${value.name}); it is not running yet. `
|
||||
+ 'Use cordis_run to activate this Package.',
|
||||
}],
|
||||
presentationMeta: (_args, value) => ({ pluginId: value.pluginId, packageId: value.packageId }),
|
||||
},
|
||||
execute(args, exec) {
|
||||
const plugin = args.plugin.kind === 'new'
|
||||
? { kind: 'new' as const, idPrefix: args.plugin.idPrefix }
|
||||
: { kind: 'existing' as const, pluginId: CordisDynamicPluginId(args.plugin.pluginId) }
|
||||
const receipt = ctx.dynamicCordisRunner.define({
|
||||
sessionId: requireAgent(exec).id,
|
||||
plugin,
|
||||
name: args.name,
|
||||
purpose: args.purpose,
|
||||
code: {
|
||||
...args.code.host === undefined ? {} : { host: args.code.host },
|
||||
...args.code.client === undefined ? {} : { client: args.code.client },
|
||||
},
|
||||
})
|
||||
return Promise.resolve({
|
||||
...receipt,
|
||||
pluginId: String(receipt.pluginId),
|
||||
packageId: String(receipt.packageId),
|
||||
})
|
||||
},
|
||||
presentCall: presentDefineCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_run',
|
||||
description:
|
||||
'Activate one exact Package of a dynamic Plugin. Use mode:"run" for the first activation, restarting '
|
||||
+ 'currentPackageId, or rollback. When current exists, use mode:"update" to switch to a different Package, '
|
||||
+ 'even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and '
|
||||
+ 'returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the '
|
||||
+ 'browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after '
|
||||
+ 'complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or '
|
||||
+ 'technical failure is reported through state and steering. After a technical failure, read diagnostics with '
|
||||
+ 'cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after '
|
||||
+ 'the user rejects it.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable Plugin ID returned by cordis_define.' },
|
||||
packageId: { type: 'string', required: true, description: 'Exact immutable Package ID to activate under that Plugin.' },
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['run', 'update'],
|
||||
description: 'Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => {
|
||||
const result = requireJsonObject(value)
|
||||
const pluginId = requireJsonString(result, 'pluginId')
|
||||
const packageId = requireJsonString(result, 'packageId')
|
||||
const pluginRunId = requireJsonString(result, 'pluginRunId')
|
||||
return [{
|
||||
type: 'text',
|
||||
text: result.status === 'awaiting-approval'
|
||||
? `${pluginId}/${packageId} is awaiting user approval (${pluginRunId}).`
|
||||
: result.status === 'starting'
|
||||
? `${pluginId}/${packageId} is starting asynchronously (${pluginRunId}).`
|
||||
: `${pluginId}/${packageId} is running (${pluginRunId}).`,
|
||||
}]
|
||||
},
|
||||
presentationMeta: (_args, value) => {
|
||||
const result = requireJsonObject(value)
|
||||
return {
|
||||
pluginId: requireJsonString(result, 'pluginId'),
|
||||
packageId: requireJsonString(result, 'packageId'),
|
||||
pluginRunId: requireJsonString(result, 'pluginRunId'),
|
||||
}
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const agent = requireAgent(exec)
|
||||
const pluginId = CordisDynamicPluginId(args.pluginId)
|
||||
const packageId = CordisDynamicPackageId(args.packageId)
|
||||
const receipt = await ctx.dynamicCordisRunner.run(agent, pluginId, packageId, args.mode, exec.signal)
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
if (receipt.status !== 'running') {
|
||||
return {
|
||||
status: receipt.status,
|
||||
pluginId: args.pluginId,
|
||||
packageId: args.packageId,
|
||||
pluginRunId: String(receipt.pluginRunId),
|
||||
mode: receipt.mode,
|
||||
...receipt.currentPackageId === undefined ? {} : { currentPackageId: String(receipt.currentPackageId) },
|
||||
nextPackageId: String(receipt.nextPackageId),
|
||||
}
|
||||
}
|
||||
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
|
||||
const fiber = row?.activeRun?.pluginRunId === receipt.pluginRunId ? row.activeRun.fiber : undefined
|
||||
return {
|
||||
status: 'running',
|
||||
pluginId: args.pluginId,
|
||||
packageId: args.packageId,
|
||||
pluginRunId: String(receipt.pluginRunId),
|
||||
currentPackageId: String(receipt.currentPackageId),
|
||||
...receipt.nextPackageId === undefined ? {} : { nextPackageId: String(receipt.nextPackageId) },
|
||||
host: {
|
||||
status: fiber === undefined ? 'absent' : missingServices(ctx, fiber).length === 0 ? 'running' : 'waiting',
|
||||
provides: fiber === undefined ? [] : providedServices(ctx, fiber),
|
||||
waitingFor: fiber === undefined ? [] : missingServices(ctx, fiber),
|
||||
},
|
||||
client: {
|
||||
status: receipt.clientWaitingFor === undefined
|
||||
? 'absent'
|
||||
: receipt.clientWaitingFor.length === 0 ? 'running' : 'waiting',
|
||||
waitingFor: [...(receipt.clientWaitingFor ?? [])],
|
||||
},
|
||||
}
|
||||
},
|
||||
presentCall: presentRunCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_stop',
|
||||
description:
|
||||
'Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the '
|
||||
+ 'Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update '
|
||||
+ 'directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects '
|
||||
+ 'temporarily; use cordis_undefine for permanent removal.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to stop.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'object', additionalProperties: false, properties: { pluginId: { type: 'string', required: true } } },
|
||||
render: (_args, value) => [{ type: 'text', text: `Dynamic Plugin ${value.pluginId} is stopped; its definition and versions remain.` }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const receipt = await ctx.dynamicCordisRunner.stop(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
|
||||
if (!receipt.ok && receipt.reason !== 'not-running') throw new Error(receipt.message)
|
||||
return { pluginId: args.pluginId }
|
||||
},
|
||||
presentCall: presentStopCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_undefine',
|
||||
description:
|
||||
'Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, '
|
||||
+ 'first stop it and cancel the request, then delete every Package, grant, and version pointer. After this '
|
||||
+ 'returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards '
|
||||
+ 'retain only a "Plugin removed" record. Do not call this Tool when versions must remain available for restart '
|
||||
+ 'or rollback; use cordis_stop instead.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to remove permanently.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
pluginId: { type: 'string', required: true },
|
||||
wasRunning: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: `Removed dynamic Plugin ${value.pluginId} and all of its Packages.` }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const receipt = await ctx.dynamicCordisRunner.undefine(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
return { pluginId: args.pluginId, wasRunning: receipt.wasRunning }
|
||||
},
|
||||
presentCall: presentUndefineCall,
|
||||
}))
|
||||
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
const ids = referencedPluginIds(messages)
|
||||
if (ids.length === 0) return decision
|
||||
signal.throwIfAborted()
|
||||
const contexts = ids.map((id) => {
|
||||
const reference = ctx.dynamicCordisRunner.reference(agent, CordisDynamicPluginId(id))
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: reference === undefined ? renderUnavailableReference(id) : renderReference(reference),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: name, form: 'instructions' },
|
||||
})
|
||||
})
|
||||
return { kind: 'enter', messages: [...decision.messages, ...contexts] }
|
||||
})
|
||||
}
|
||||
|
||||
function requireJsonObject(value: JsonValue): Record<string, JsonValue> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('expected a JSON object')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requireJsonString(value: Record<string, JsonValue>, key: string): string {
|
||||
const field = value[key]
|
||||
if (typeof field !== 'string') throw new Error(`expected JSON string field "${key}"`)
|
||||
return field
|
||||
}
|
||||
|
||||
type SelfState = 'defined' | 'awaiting-approval' | 'client-pending' | 'stopped' | 'running' | 'waiting' | 'failed'
|
||||
|
||||
function selfSummary(reference: DynamicCordisReference & { packages?: readonly unknown[] }): Record<string, JsonValue> {
|
||||
const latest = reference.latestRun
|
||||
const state = selfState(reference)
|
||||
return {
|
||||
pluginId: String(reference.pluginId),
|
||||
name: reference.name,
|
||||
packageCount: reference.packages?.length ?? 1,
|
||||
state,
|
||||
...reference.currentPackageId === undefined ? {} : { currentPackageId: String(reference.currentPackageId) },
|
||||
...reference.nextPackageId === undefined ? {} : { nextPackageId: String(reference.nextPackageId) },
|
||||
...reference.activeRun === undefined ? {} : {
|
||||
activeRun: {
|
||||
pluginRunId: String(reference.activeRun.pluginRunId),
|
||||
packageId: String(reference.activeRun.packageId),
|
||||
},
|
||||
},
|
||||
...latest?.status !== 'awaiting-approval' ? {} : {
|
||||
pendingApproval: {
|
||||
pluginRunId: String(latest.pluginRunId),
|
||||
packageId: String(latest.packageId),
|
||||
mode: latest.mode,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function selfState(reference: DynamicCordisReference): SelfState {
|
||||
const status = reference.latestRun?.status
|
||||
if (status === 'awaiting-approval') return 'awaiting-approval'
|
||||
if (status === 'client-pending' || status === 'starting-host') return 'client-pending'
|
||||
if (status === 'failed' || status === 'rejected' || status === 'cancelled') return 'failed'
|
||||
if (status === 'waiting') return 'waiting'
|
||||
if (status === 'running') return 'running'
|
||||
if (reference.activeRun !== undefined) return 'running'
|
||||
return reference.currentPackageId === undefined ? 'defined' : 'stopped'
|
||||
}
|
||||
|
||||
function inspectSelfPackage(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
pluginId: ReturnType<typeof CordisDynamicPluginId>,
|
||||
packageId: ReturnType<typeof CordisDynamicPackageId>,
|
||||
): Record<string, JsonValue> {
|
||||
const inspected = ctx.dynamicCordisRunner.inspectPackage(agent, pluginId, packageId)
|
||||
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
|
||||
const pkg = row?.packages.find(candidate => candidate.packageId === packageId)
|
||||
const active = row?.activeRun?.packageId === packageId ? row.activeRun : undefined
|
||||
const latest = inspected.latestRun?.packageId === packageId ? inspected.latestRun : undefined
|
||||
const hostWaiting = active?.fiber === undefined ? [...(latest?.host.waitingFor ?? [])] : missingServices(ctx, active.fiber)
|
||||
const hostStatus = pkg?.hasHostHalf !== true
|
||||
? 'absent'
|
||||
: latest?.host.status ?? (active === undefined ? 'stopped' : hostWaiting.length === 0 ? 'running' : 'waiting')
|
||||
const clientStatus = pkg?.hasClientHalf !== true
|
||||
? 'absent'
|
||||
: latest?.client.status ?? 'stopped'
|
||||
return {
|
||||
mode: 'package',
|
||||
plugin: selfSummary(inspected),
|
||||
packageId: String(packageId),
|
||||
name: inspected.name,
|
||||
purpose: inspected.purpose,
|
||||
code: inspected.code,
|
||||
runtime: {
|
||||
state: selfState(inspected),
|
||||
host: {
|
||||
status: hostStatus,
|
||||
provides: active?.fiber === undefined ? [] : providedServices(ctx, active.fiber),
|
||||
waitingFor: hostWaiting,
|
||||
handlers: active?.handlers ?? [],
|
||||
...latest?.host.error === undefined ? {} : { error: latest.host.error },
|
||||
},
|
||||
client: {
|
||||
status: clientStatus,
|
||||
waitingFor: [...(latest?.client.waitingFor ?? [])],
|
||||
...latest?.client.error === undefined ? {} : { error: latest.client.error },
|
||||
...active?.renderFailure === undefined ? {} : { renderFailure: active.renderFailure },
|
||||
},
|
||||
},
|
||||
} as unknown as Record<string, JsonValue>
|
||||
}
|
||||
|
||||
function referencedPluginIds(messages: readonly UserMessage[]): string[] {
|
||||
const found = new Set<string>()
|
||||
const pattern = /(?:^|\s)@([a-z]{3,6}-\d+)(?=\s|$)/g
|
||||
for (const message of messages) {
|
||||
if (message.source.kind !== 'user') continue
|
||||
const text = message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
||||
for (const match of text.matchAll(pattern)) if (match[1] !== undefined) found.add(match[1])
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
function renderReference(reference: ReturnType<Context['dynamicCordisRunner']['reference']> & {}): string {
|
||||
const mode = reference.currentPackageId === undefined ? 'run' : 'update'
|
||||
return [
|
||||
'<cordis_dynamic_plugin_context>',
|
||||
JSON.stringify(reference, null, 2),
|
||||
'',
|
||||
`The user explicitly referenced @${reference.pluginId}. Use Package ${reference.packageId} as the base for this modification.`,
|
||||
`Before modifying it, call cordis_inspect_self with pluginId="${reference.pluginId}" and packageId="${reference.packageId}" to read the exact metadata and source.`,
|
||||
`Use cordis_define with plugin.kind="existing" and the original pluginId="${reference.pluginId}" to append an immutable Package.`,
|
||||
`Do not create a new Plugin for this request. After cordis_define succeeds, call cordis_run mode="${mode}" with the returned packageId.`,
|
||||
'</cordis_dynamic_plugin_context>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderUnavailableReference(id: string): string {
|
||||
return [
|
||||
'<cordis_dynamic_plugin_context>',
|
||||
`The user explicitly referenced @${id}, but this Plugin is unavailable in the current Session.`,
|
||||
'It may have been removed, belong to another Session, or have been lost when the DSH process restarted.',
|
||||
'Do not claim that it was updated or silently create a replacement Plugin. Tell the user that the reference is currently unavailable.',
|
||||
'</cordis_dynamic_plugin_context>',
|
||||
].join('\n')
|
||||
}
|
||||
332
packages/extensions/tool-cordis/src/inspect.ts
Normal file
332
packages/extensions/tool-cordis/src/inspect.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Text renderers for `cordis_runtime_inspect`. Live facts come from the service store and
|
||||
* the plugin registry; what each service CAN DO comes from the generated
|
||||
* `api-catalog.ts`. This module owns the join of the two plus presentation: which
|
||||
* lines a section prints, how compact the default report stays, and what an exact
|
||||
* `name` adds.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/inspect
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
// Type-only: resolves `ctx.dynamicCordisRunner` (the registry this report reads).
|
||||
import type {} from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, ServiceApiMethod, TypeApiEntry } from './api-catalog.ts'
|
||||
import { FiberState, STATE_LABELS } from './fiber-state.ts'
|
||||
|
||||
/** One live service joined with what the generated catalog knows about it. */
|
||||
interface LiveService {
|
||||
/** The `ctx.<name>` key. */
|
||||
name: string
|
||||
/** Plugin fiber providing it. */
|
||||
owner: string
|
||||
/** Lifecycle state of that fiber; `active` while it is serving. */
|
||||
state: string
|
||||
/** First sentence of the catalog summary; empty when the catalog has no entry. */
|
||||
summary: string
|
||||
/** Whether the generated catalog carries signatures for it. */
|
||||
catalogued: boolean
|
||||
/** Public method signatures from the catalog, empty for an uncatalogued service. */
|
||||
methods: readonly string[]
|
||||
}
|
||||
|
||||
/** The live service registrations, read from the reflect store. */
|
||||
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
|
||||
const store = ctx.reflect.store
|
||||
return Object.getOwnPropertySymbols(store)
|
||||
.map(key => store[key])
|
||||
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary as prose. JSDoc may name a symbol with an inline `{@link Foo.bar}`
|
||||
* tag, which the generated catalog retains verbatim; a report is read, not
|
||||
* compiled, so the link syntax is spent context and the bare symbol says the same
|
||||
* thing.
|
||||
*/
|
||||
function plainSummary(summary: string): string {
|
||||
return summary.replace(/\{@link\s+([^}]+)\}/g, '$1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every service this process provides, joined with the generated catalog: what is
|
||||
* RUNNING comes from the store, what each service CAN DO comes from the catalog,
|
||||
* and a live service the catalog does not cover stays in the list as reachable
|
||||
* with no signatures rather than being dropped.
|
||||
*/
|
||||
function liveServices(ctx: Context, api: readonly ServiceApiEntry[]): LiveService[] {
|
||||
const catalogued = new Map(api.map(entry => [entry.key, entry]))
|
||||
return liveImpls(ctx)
|
||||
.map((impl) => {
|
||||
const entry = catalogued.get(impl.name)
|
||||
return {
|
||||
name: impl.name,
|
||||
owner: impl.fiber.name,
|
||||
state: STATE_LABELS[impl.fiber.state],
|
||||
summary: entry === undefined ? '' : plainSummary(entry.summary),
|
||||
catalogued: entry !== undefined,
|
||||
methods: entry === undefined ? [] : entry.methods.map(method => method.signature),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Catalogued services with no live provider: loadable in principle, absent here. */
|
||||
function absentServices(ctx: Context, api: readonly ServiceApiEntry[]): string[] {
|
||||
const live = new Set(liveImpls(ctx).map(impl => impl.name))
|
||||
return api.filter(entry => !live.has(entry.key)).map(entry => entry.key).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fiber is `root` itself or mounted anywhere inside `root`'s subtree.
|
||||
* @param fiber - the fiber to locate.
|
||||
* @param root - the subtree root to test against.
|
||||
* @returns true when `fiber` belongs to that subtree.
|
||||
*/
|
||||
export function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
let current = fiber
|
||||
while (true) {
|
||||
if (current === root) return true
|
||||
const parent = current.parent.fiber
|
||||
if (parent === current) return false
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names provided by one mount's fiber subtree.
|
||||
* @param ctx - the runtime whose service registrations are inspected.
|
||||
* @param fiber - the root of the mounted fiber subtree.
|
||||
* @returns the provided service names in lexical order.
|
||||
*/
|
||||
export function providedServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return liveImpls(ctx)
|
||||
.filter(impl => withinFiber(impl.fiber, fiber))
|
||||
.map(impl => impl.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Services a fiber declared in `inject` that do not exist yet — a settled fiber
|
||||
* that is not active is waiting on exactly these (legal cordis semantics: it
|
||||
* activates when the service appears).
|
||||
* @param ctx - the context to resolve service existence against.
|
||||
* @param fiber - the fiber whose `inject` declarations are checked.
|
||||
* @returns the missing service names, in declaration order.
|
||||
*/
|
||||
export function missingServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `services` section: every live ctx service with its owning fiber and, when
|
||||
* the generated catalog covers it, a one-line summary. The `api` section is the
|
||||
* one that carries signatures; this one answers what exists and who provides it.
|
||||
* @param ctx - the runtime to enumerate.
|
||||
* @param api - the generated service entries whose summaries annotate the live ones.
|
||||
* @returns one line per service, or a single placeholder line when none are provided.
|
||||
*/
|
||||
export function describeServices(ctx: Context, api: readonly ServiceApiEntry[] = SERVICE_API): string[] {
|
||||
const live = liveServices(ctx, api)
|
||||
if (live.length === 0) return ['(no services provided)']
|
||||
return live.map((service) => {
|
||||
const state = service.state === STATE_LABELS[FiberState.ACTIVE] ? '' : `, ${service.state}`
|
||||
const summary = service.summary === '' ? '' : ` — ${service.summary}`
|
||||
return `- ${service.name} (provided by ${service.owner}${state})${summary}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). 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.
|
||||
*/
|
||||
export function describePlugins(ctx: Context): string[] {
|
||||
const fibers: Fiber[] = []
|
||||
for (const runtime of ctx.registry.values()) {
|
||||
for (const fiber of runtime.fibers) fibers.push(fiber)
|
||||
}
|
||||
return fibers
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `tools` section: the model-facing tool names the CALLING agent can see
|
||||
* (its scoped layer shadowing/joining the restricted global tool set) — the
|
||||
* honest answer to the tool description's "what you can call".
|
||||
* @param ctx - the runtime whose tool registry is read.
|
||||
* @param scope - the calling agent (the viewing scope); omitted = global view.
|
||||
* @returns one line per visible tool.
|
||||
*/
|
||||
export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
|
||||
return ctx.tools.schemas(scope).map(schema => `- ${schema.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `temporary` section: one line per dynamic package this session defined,
|
||||
* with its metadata, which halves exist, the host half's lifecycle state and
|
||||
* provides/waits, the invoke methods it registered, and the last browser-half
|
||||
* load report. Session-scoped like every runner verb.
|
||||
* @param ctx - the runtime the packages live in.
|
||||
* @param agent - the calling agent; without one there is no definition space to report.
|
||||
* @returns one line per package, or a single placeholder line when none exist.
|
||||
*/
|
||||
export function describeDynamic(ctx: Context, agent?: Agent): string[] {
|
||||
const rows = agent === undefined ? [] : ctx.dynamicCordisRunner.snapshot(agent)
|
||||
if (rows.length === 0) {
|
||||
return ['No dynamic Plugins are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.']
|
||||
}
|
||||
return rows.flatMap((row) => {
|
||||
const head = `- Plugin ${row.pluginId}; current: ${row.currentPackageId ?? 'none'}; next: ${row.nextPackageId ?? 'none'}`
|
||||
+ (row.activeRun === undefined
|
||||
? '; stopped'
|
||||
: `; active: ${row.activeRun.packageId} as ${row.activeRun.pluginRunId}`)
|
||||
const packages = row.packages.map((pkg) => {
|
||||
const halves = [...pkg.hasHostHalf ? ['host'] : [], ...pkg.hasClientHalf ? ['client'] : []].join('+')
|
||||
const active = row.activeRun?.packageId === pkg.packageId ? row.activeRun : undefined
|
||||
if (active === undefined) return ` - ${pkg.packageId}: ${pkg.name} (${halves}) — ${pkg.purpose}`
|
||||
const fiber = active.fiber
|
||||
const state = fiber === undefined ? 'running' : fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[fiber.state]
|
||||
const provides = fiber === undefined ? [] : providedServices(ctx, fiber)
|
||||
const waiting = fiber === undefined ? [] : missingServices(ctx, fiber)
|
||||
const failure = active.renderFailure
|
||||
const rendered = failure === undefined
|
||||
? ''
|
||||
: `; CLIENT RENDER FAILED at ${failure.slot}: ${failure.message}${failure.abdicated ? ' (entry removed)' : ''}`
|
||||
return ` - ${pkg.packageId}: ${pkg.name} [${state}, ${active.pluginRunId}] (${halves}) — ${pkg.purpose}`
|
||||
+ `; provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}`
|
||||
+ (active.handlers.length === 0 ? '' : `; host methods: ${active.handlers.join(', ')}`)
|
||||
+ rendered
|
||||
})
|
||||
return [head, ...packages]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The transitive closure of catalogued type shapes referenced (word-bounded)
|
||||
* by the seed texts — the runtime scoping that keeps the `api` section to the
|
||||
* shapes the LIVE signatures actually mention.
|
||||
*/
|
||||
function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEntry[] {
|
||||
const included = new Map<string, TypeApiEntry>()
|
||||
let frontier = seeds
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const entry of types) {
|
||||
if (included.has(entry.name)) continue
|
||||
const pattern = new RegExp(`\\b${entry.name}\\b`)
|
||||
if (frontier.some(text => pattern.test(text))) {
|
||||
included.set(entry.name, entry)
|
||||
next.push(entry.declaration)
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return [...included.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Render one live catalogued service; `documented` is non-empty only for an exact-name report. */
|
||||
function serviceLines(
|
||||
service: LiveService,
|
||||
documented: readonly ServiceApiMethod[],
|
||||
): string[] {
|
||||
const lines = [`- ${service.name} — ${service.summary}`]
|
||||
for (const signature of service.methods) {
|
||||
const contract = documented.find(entry => entry.signature === signature)
|
||||
if (contract !== undefined) {
|
||||
lines.push(` ${contract.description}`)
|
||||
for (const parameter of contract.parameters) lines.push(` @param ${parameter.name} — ${parameter.description}`)
|
||||
if (contract.returns !== undefined) lines.push(` @returns ${contract.returns}`)
|
||||
for (const failure of contract.throws ?? []) lines.push(` @throws ${failure}`)
|
||||
}
|
||||
lines.push(` ${signature}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated catalog against the live runtime: live catalogued services with methods,
|
||||
* uncatalogued live services with owners, absent loadable services, referenced type shapes, and
|
||||
* inherited Context APIs.
|
||||
* @param ctx - the runtime to intersect the catalog with.
|
||||
* @param api - generated service entries, replaceable in tests.
|
||||
* @param name - exact live service key whose methods should include structured contracts; omitted for the compact catalog.
|
||||
* @param inherited - inherited `ctx` entries, replaceable in tests.
|
||||
* @param types - public type shapes, replaceable in tests.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeApi(
|
||||
ctx: Context,
|
||||
api: readonly ServiceApiEntry[] = SERVICE_API,
|
||||
name?: string,
|
||||
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
|
||||
types: readonly TypeApiEntry[] = TYPE_API,
|
||||
): string[] {
|
||||
const live = liveServices(ctx, api)
|
||||
const byKey = new Map(api.map(entry => [entry.key, entry]))
|
||||
const lines: string[] = []
|
||||
let selected = live.filter(service => service.catalogued)
|
||||
let documented: readonly ServiceApiMethod[] = []
|
||||
if (name !== undefined) {
|
||||
const entry = byKey.get(name)
|
||||
if (entry === undefined) throw new Error(`no catalogued service named "${name}"`)
|
||||
const service = live.find(candidate => candidate.name === name)
|
||||
if (service === undefined) throw new Error(`catalogued service "${name}" is not running`)
|
||||
selected = [service]
|
||||
documented = entry.methods
|
||||
}
|
||||
for (const service of selected) lines.push(...serviceLines(service, documented))
|
||||
if (name === undefined) {
|
||||
for (const service of live.filter(candidate => !candidate.catalogued)) {
|
||||
lines.push(`- ${service.name} (provided by ${service.owner}) — running, but this catalog has no signature for it;`
|
||||
+ ` inject: ['${service.name}'] still reaches it`)
|
||||
}
|
||||
const notRunning = absentServices(ctx, api)
|
||||
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
|
||||
}
|
||||
const shapes = typeClosure(selected.flatMap(service => [...service.methods]), types)
|
||||
if (shapes.length > 0) {
|
||||
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
|
||||
for (const shape of shapes) {
|
||||
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
|
||||
}
|
||||
}
|
||||
if (name === undefined) {
|
||||
lines.push('inherited ctx API:')
|
||||
for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The `events` section: every harness event with its dispatch mode, one-line
|
||||
* summary, and exact signature, closed by the waterfall caution.
|
||||
* @param events - the event catalog (the generated one by default; injectable for tests).
|
||||
* @param name - exact event name whose signature should include its structured contract; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
|
||||
let selected = events
|
||||
if (name !== undefined) {
|
||||
const event = events.find(candidate => candidate.name === name)
|
||||
if (!event) throw new Error(`no catalogued event named "${name}"`)
|
||||
selected = [event]
|
||||
}
|
||||
const lines = selected.flatMap((event) => {
|
||||
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
|
||||
if (name !== undefined) {
|
||||
entry.push(` ${event.description}`)
|
||||
for (const parameter of event.parameters) entry.push(` @param ${parameter.name} — ${parameter.description}`)
|
||||
}
|
||||
entry.push(` ${event.signature}`)
|
||||
return entry
|
||||
})
|
||||
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.')
|
||||
return lines
|
||||
}
|
||||
30
packages/extensions/tool-cordis/src/invariant.ts
Normal file
30
packages/extensions/tool-cordis/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-cordis-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
102
packages/extensions/tool-cordis/src/present.ts
Normal file
102
packages/extensions/tool-cordis/src/present.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/** Pure replay-safe render intents for Cordis tools. */
|
||||
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Render a runtime-inspection call.
|
||||
* @param args - requested runtime category and optional member name.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentRuntimeInspectCall(args: { what?: string; name?: string }): GenericCallView {
|
||||
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
|
||||
return { card: 'generic', kind: 'read', title: target === undefined ? 'Inspect Cordis runtime' : `Inspect Cordis runtime: ${target}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render provider-directory inspection.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentInspectListCall(): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: 'List Cordis Inspect Providers' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one provider query.
|
||||
* @param args - target platform, provider, and method.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentInspectQueryCall(args: { platform: string; provider: string; method: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: `Query Cordis ${args.platform} ${args.provider}.${args.method}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render layered self-inspection.
|
||||
* @param args - optional Plugin and Package identity.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentInspectSelfCall(args: { pluginId?: string; packageId?: string }): GenericCallView {
|
||||
const target = args.pluginId === undefined
|
||||
? 'dynamic Cordis Plugins'
|
||||
: args.packageId === undefined ? args.pluginId : `${args.pluginId}/${args.packageId}`
|
||||
return { card: 'generic', kind: 'read', title: `Inspect ${target}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an immutable Package source-inspection call.
|
||||
* @param args - exact Plugin and Package identity.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentPackageInspectCall(args: { pluginId: string; packageId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: `Inspect Cordis Package ${args.pluginId}/${args.packageId}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a new or appended Package definition.
|
||||
* @param args - target Plugin, Package metadata, and source halves.
|
||||
* @returns replay-safe generic call presentation with source in raw input.
|
||||
*/
|
||||
export function presentDefineCall(args: {
|
||||
plugin: { kind: 'new'; idPrefix: string } | { kind: 'existing'; pluginId: string }
|
||||
name: string
|
||||
purpose: string
|
||||
code: { host?: string; client?: string }
|
||||
}): GenericCallView {
|
||||
const target = args.plugin.kind === 'new' ? `new ${args.plugin.idPrefix}-*` : args.plugin.pluginId
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: `Register Cordis Plugin "${args.name}" for ${target}: ${args.purpose}`,
|
||||
rawInput: args.code,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Plugin removal.
|
||||
* @param args - Plugin identity to remove.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentUndefineCall(args: { pluginId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'delete', title: `Remove Cordis Plugin ${args.pluginId}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one exact Package activation.
|
||||
* @param args - Plugin, Package, and activation mode.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentRunCall(args: { pluginId: string; packageId: string; mode: 'run' | 'update' }): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: `${args.mode === 'update' ? 'Update' : 'Run'} Cordis Plugin ${args.pluginId} · ${args.packageId}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Plugin stop.
|
||||
* @param args - Plugin identity to stop.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentStopCall(args: { pluginId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'execute', title: `Stop Cordis Plugin ${args.pluginId}` }
|
||||
}
|
||||
107
packages/extensions/tool-cordis/src/prompt.ts
Normal file
107
packages/extensions/tool-cordis/src/prompt.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/** Model guidance shared by the Cordis dynamic-plugin tools. */
|
||||
|
||||
export const CORDIS_SYSTEM_PROMPT = `# Dynamic Cordis Plugins
|
||||
|
||||
Dynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.
|
||||
|
||||
- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.
|
||||
- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.
|
||||
|
||||
## Make the user-facing plan clear first
|
||||
|
||||
- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.
|
||||
- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.
|
||||
- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.
|
||||
- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.
|
||||
- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.
|
||||
- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.
|
||||
- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.
|
||||
- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.
|
||||
- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.
|
||||
|
||||
## Recommended workflow and Tools
|
||||
|
||||
Before creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.
|
||||
|
||||
1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.
|
||||
2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.
|
||||
3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.
|
||||
4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.
|
||||
5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.
|
||||
6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.
|
||||
7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.
|
||||
|
||||
- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.
|
||||
- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.
|
||||
- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.
|
||||
|
||||
## Identity, versions, and approval
|
||||
|
||||
- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.
|
||||
- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.
|
||||
- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.
|
||||
- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.
|
||||
- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.
|
||||
- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.
|
||||
- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.
|
||||
|
||||
When the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:
|
||||
|
||||
1. Call cordis_inspect_self(pluginId, packageId) to read the target source.
|
||||
2. Use cordis_define in existing mode to append a Package to the same Plugin.
|
||||
3. Call cordis_run in run or update mode according to the version relationship.
|
||||
|
||||
Never silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.
|
||||
|
||||
## High-frequency errors that must be avoided
|
||||
|
||||
### Services: ctx.get and inject
|
||||
|
||||
- Read an optional Service with ctx.get('serviceName') by default and handle undefined.
|
||||
- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.
|
||||
- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.
|
||||
|
||||
\`\`\`js
|
||||
return {
|
||||
inject: ['requiredService'],
|
||||
apply(ctx) {
|
||||
ctx.requiredService.someMethod()
|
||||
const optionalService = ctx.get('optionalService')
|
||||
if (optionalService !== undefined) optionalService.someMethod()
|
||||
},
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Code: use plain JavaScript only
|
||||
|
||||
- Host and Client code is not transformed by TypeScript, JSX, or a bundler.
|
||||
- Do not use TypeScript types, as, decorators, import, require, or JSX.
|
||||
- Client React code must use React.createElement(...); never write <Component />.
|
||||
- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.
|
||||
|
||||
### Data: do not serialize live data
|
||||
|
||||
- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.
|
||||
- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.
|
||||
- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.
|
||||
|
||||
### Lifecycle: every side effect must be reversible
|
||||
|
||||
- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.
|
||||
- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.
|
||||
- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.
|
||||
|
||||
## Host and Client
|
||||
|
||||
- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.
|
||||
- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.
|
||||
- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.
|
||||
- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.
|
||||
- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.
|
||||
|
||||
## Asynchronous results and recovery
|
||||
|
||||
- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.
|
||||
- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.
|
||||
- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.
|
||||
- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.`
|
||||
101
packages/extensions/tool-cordis/src/providers.ts
Normal file
101
packages/extensions/tool-cordis/src/providers.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/** First-party Host inspect providers registered by the Cordis tool package. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { HOST_BUILTIN_INSPECTION } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { EVENT_API, queryEventApi, queryServiceApi } from './api-catalog.ts'
|
||||
|
||||
const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const
|
||||
const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const
|
||||
const SERVICE_INPUT = exactInput('service', 'Exact Service key. Omit it for the compact Service and method-signature directory.')
|
||||
const EVENT_INPUT = exactInput('event', 'Exact Event name. Omit it for the compact Event and listener-signature directory.')
|
||||
const SERVICE_OUTPUT = {
|
||||
description: 'Compact Service directory, or one exact Service contract with only its referenced type declarations.',
|
||||
} as const
|
||||
const EVENT_OUTPUT = {
|
||||
description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.',
|
||||
} as const
|
||||
const HOST_EVENTS = EVENT_API.filter(event => !event.name.startsWith('cordis/'))
|
||||
|
||||
/**
|
||||
* Construct Host providers over generated Catalogs, evaluator declarations, and live Tool scope.
|
||||
* @param ctx - Host context used for Agent-scoped live Tool queries.
|
||||
* @returns registrations for static catalogs and live Host capabilities.
|
||||
*/
|
||||
export function hostInspectProviders(ctx: Context): HostCordisInspectProviderRegistration[] {
|
||||
return [
|
||||
registration(
|
||||
'Service',
|
||||
'Progressive Host Service discovery: compact capability/signature directory, then one exact coding contract.',
|
||||
'listService',
|
||||
input => queryServiceApi(readExact(input, 'service')) as unknown as JsonValue,
|
||||
SERVICE_INPUT,
|
||||
SERVICE_OUTPUT,
|
||||
),
|
||||
registration(
|
||||
'Event',
|
||||
'Progressive Host Event discovery: compact listener directory, then one exact event contract.',
|
||||
'listEvents',
|
||||
input => queryEventApi(readExact(input, 'event'), HOST_EVENTS) as unknown as JsonValue,
|
||||
EVENT_INPUT,
|
||||
EVENT_OUTPUT,
|
||||
),
|
||||
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Host half.', 'listBuiltins', () => ({
|
||||
builtins: HOST_BUILTIN_INSPECTION,
|
||||
referencedTypes: [],
|
||||
} as unknown as JsonValue)),
|
||||
{
|
||||
manifest: {
|
||||
id: 'Tool',
|
||||
description: 'Tools visible to the requesting Agent, including scoped and dynamic registrations.',
|
||||
methods: [{
|
||||
name: 'listTools',
|
||||
description: 'Return every Tool schema currently callable by this Agent.',
|
||||
inputSchema: EMPTY_INPUT,
|
||||
outputSchema: ANY_OUTPUT,
|
||||
}],
|
||||
},
|
||||
query(method, _input, context) {
|
||||
if (method !== 'listTools') throw new Error(`unknown Tool inspect method "${method}"`)
|
||||
return Promise.resolve({ tools: ctx.tools.schemas(context.agent) } as unknown as JsonValue)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function registration(
|
||||
id: string,
|
||||
description: string,
|
||||
method: string,
|
||||
query: (input: JsonValue | undefined) => JsonValue | Promise<JsonValue>,
|
||||
inputSchema: JsonValue = EMPTY_INPUT,
|
||||
outputSchema: JsonValue = ANY_OUTPUT,
|
||||
): HostCordisInspectProviderRegistration {
|
||||
return {
|
||||
manifest: {
|
||||
id,
|
||||
description,
|
||||
methods: [{
|
||||
name: method,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
}],
|
||||
},
|
||||
async query(requested, input) {
|
||||
if (requested !== method) throw new Error(`unknown ${id} inspect method "${requested}"`)
|
||||
return await query(input)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function exactInput(field: string, description: string): JsonValue {
|
||||
return { type: 'object', properties: { [field]: { type: 'string', description } }, additionalProperties: false }
|
||||
}
|
||||
|
||||
function readExact(input: JsonValue | undefined, field: string): string | undefined {
|
||||
if (input === undefined || input === null || Array.isArray(input) || typeof input !== 'object') return undefined
|
||||
const value = input[field]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
287
packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts
Normal file
287
packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Context, CordisError, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Direct regressions for the vendored Cordis ownership substrate used by
|
||||
* tool-cordis's dynamic plugin tree and every other harness plugin.
|
||||
*/
|
||||
|
||||
describe('Cordis effect ownership', () => {
|
||||
it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => {
|
||||
const ctx = new Context()
|
||||
const setupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
let setupFinished = false
|
||||
let cleanupFinished = false
|
||||
|
||||
ctx.effect(async () => {
|
||||
restarted = ctx.fiber.restart()
|
||||
await setupGate.promise
|
||||
setupFinished = true
|
||||
return async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}
|
||||
}, 'reentrant-restart')
|
||||
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
setupGate.resolve(undefined)
|
||||
await cleanupStarted.promise
|
||||
expect(setupFinished).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield () => { cleanups += 1 }
|
||||
throw new Error('setup failed')
|
||||
}, 'throwing-setup')).toThrow('setup failed')
|
||||
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}
|
||||
restarted = ctx.fiber.restart()
|
||||
throw new Error('setup failed after restart')
|
||||
}, 'reentrant-throw')).toThrow('setup failed after restart')
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps ordinary teardown synchronous and the public disposer single-shot', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect')
|
||||
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects cleanup-time registration while a restart is unloading', async () => {
|
||||
const ctx = new Context()
|
||||
let registrationError: unknown
|
||||
|
||||
ctx.effect(() => () => {
|
||||
try {
|
||||
ctx.effect(() => () => {}, 'too-late')
|
||||
} catch (error) {
|
||||
registrationError = error
|
||||
}
|
||||
}, 'restart-cleanup')
|
||||
|
||||
await ctx.fiber.restart()
|
||||
expect(registrationError).toBeInstanceOf(CordisError)
|
||||
expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT')
|
||||
expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => {
|
||||
const ctx = new Context()
|
||||
let pendingCleanup = false
|
||||
let loadingCleanup = false
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'state-probe' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect')
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'state-probe',
|
||||
apply(inner) {
|
||||
expect(inner.fiber.state).toBe(FiberState.LOADING)
|
||||
inner.effect(() => () => { loadingCleanup = true }, 'loading-effect')
|
||||
},
|
||||
})
|
||||
await fiber.dispose()
|
||||
|
||||
expect(pendingCleanup).toBe(true)
|
||||
expect(loadingCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves dependencies that internal/plugin adds before child activation', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('late-inject', {})
|
||||
let applyCalls = 0
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loader-shaped' || fiber.uid === null) return
|
||||
fiber.inject['late-inject'] = {}
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'loader-shaped',
|
||||
apply() {
|
||||
applyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(applyCalls).toBe(1)
|
||||
expect(fiber.state).toBe(FiberState.ACTIVE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis child publication ownership', () => {
|
||||
it('rolls back parent and runtime ownership when internal/plugin publication throws', () => {
|
||||
const ctx = new Context()
|
||||
const plugin = { name: 'publication-failure', apply() {} }
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === plugin.name) throw new Error('publication failed')
|
||||
})
|
||||
|
||||
expect(() => ctx.plugin(plugin)).toThrow('publication failed')
|
||||
expect(ctx.registry.has(plugin)).toBe(false)
|
||||
})
|
||||
|
||||
it('contains teardown notification failures so ownership cleanup and peers complete', async () => {
|
||||
const ctx = new Context()
|
||||
const errors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
|
||||
const observed: string[] = []
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) {
|
||||
throw new Error('broken teardown observer')
|
||||
}
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed')
|
||||
})
|
||||
const child = await ctx.plugin({ name: 'contained-teardown', apply() {} })
|
||||
|
||||
await expect(child.dispose()).resolves.toBeUndefined()
|
||||
expect(observed).toEqual(['disposed'])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' }))
|
||||
expect(child.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let ownerFiber!: Fiber
|
||||
let ownerDisposal!: Promise<void>
|
||||
let childDisposal!: Promise<void>
|
||||
let childFiber!: Fiber
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loading-child' || fiber.uid === null) return
|
||||
childFiber = fiber
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}, 'loading-child-cleanup')
|
||||
ownerDisposal = ownerFiber.dispose()
|
||||
childDisposal = Promise.resolve(fiber.dispose())
|
||||
})
|
||||
|
||||
const ownerMount = ctx.plugin({
|
||||
name: 'loading-owner',
|
||||
apply(inner) {
|
||||
ownerFiber = inner.fiber
|
||||
inner.plugin({ name: 'loading-child', apply() {} })
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
void ownerDisposal.then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await Promise.all([ownerDisposal, childDisposal, ownerMount])
|
||||
expect(childFiber.uid).toBeNull()
|
||||
expect(ownerFiber.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => {
|
||||
const ctx = new Context()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin({
|
||||
name: 'owner',
|
||||
apply(inner) {
|
||||
ownerCtx = inner
|
||||
},
|
||||
})
|
||||
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
let childApplyCalls = 0
|
||||
let parentDisposal!: Promise<void>
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}, 'pending-child-cleanup')
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
parentDisposal = owner.dispose()
|
||||
})
|
||||
|
||||
const child = ownerCtx.plugin({
|
||||
name: 'child',
|
||||
apply() {
|
||||
childApplyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void parentDisposal.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await parentDisposal
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(childApplyCalls).toBe(0)
|
||||
expect(child.uid).toBeNull()
|
||||
expect(child.state).toBe(FiberState.DISPOSED)
|
||||
})
|
||||
})
|
||||
45
packages/extensions/tool-cordis/tsconfig.json
Normal file
45
packages/extensions/tool-cordis/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../cordis-host-runner"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user