mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
feat/dsh plugin (#3356)
* docs: remove .superpowers * feat(dsh-plugin): bring the DeepSeek Harness plugin into the monorepo Moves dsh-plugin-reactive-resume out of its own repository and into packages/dsh-plugin. It stays a published, public npm package — the only one here — but now builds, typechecks, tests, and lints under the same turbo tasks as everything else. The move pays for itself in the drift guard. Standalone, the plugin kept a generated snapshot of the tool names scraped from the live server card at https://rxresu.me, plus a weekly CI job to notice when that snapshot went stale. Sitting next to packages/mcp, it reads MCP_TOOL_NAME directly, so a tool rename breaks the prompt guide on the same pull request instead of days later. The snapshot, the fetch script, and the scheduled job are gone. packages/mcp gains a ./tool-names export so that import goes through the public export map rather than another workspace's src. Also flips autoInstallPeers off. The DeepSeek Harness rc packages declare peers that are host-supplied and, in one case (@deepseek-ai/dsh-type-meta), not published at all, so auto-install 404s the whole workspace. Turning it off drops only optional peers elsewhere; @neodrag/core was the single hard peer that had been arriving implicitly, and it is now declared where it is used. Full typecheck and test suites pass, and pnpm peers check reports nothing new beyond the pre-existing drizzle-orm range mismatch. Tests move from test/ to colocated src/*.test.ts and the build output from lib/ to dist/ to match repository conventions. * fix(dsh-plugin): ship a bundle manifest and target the current Harness `dsh plugin add` warned that the package "declares no dsh.bundle — installed as a plain dependency, not a profile layer", and it was right. Every other Harness plugin, in-box and third-party, ships a cordis.patch.yml and points dsh.bundle.patch at it; that declaration is what joins a package to a profile's bundle stack. Without it the package installed and then sat inert, and the README's hand-written insert row was a workaround for the gap rather than the intended way in. The peer ranges were also a generation behind. They asked for @deepseek-ai/dsh-mcp-client and dsh-system-prompt at ^0.0.1-rc.1, which cannot match the 0.1.0-rc.6 a current harness ships, so the plugin could never have resolved against the thing it targets. Both APIs are unchanged across the bump — StreamableHttpConfig still takes the same six fields and PromptSection still takes name/order/text — so this is a range correction, not a migration. That bump pays for itself elsewhere. The old generation peer-depended on @deepseek-ai/dsh-type-meta, which was never published, and working around that 404 is why merging this package turned autoInstallPeers off for the whole repository and pulled @neodrag/core in by hand. The new generation dropped that peer and publishes every other one, so both changes are reverted and pnpm-workspace.yaml is back to what it was. Because a bundle patch mounts the plugin the moment it is installed, a required apiKey would fail config validation and take the profile down before the user ever had a chance to mint a key. It now defaults to empty and apply() warns and mounts nothing, matching how dsh-honcho-memory handles the same problem. Verified by packing the tarball and installing it into a clean project with default pnpm settings: it resolves, imports, and reports its exports.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# DeepSeek Harness Plugin for Reactive Resume — Design
|
||||
|
||||
**Date:** 2026-08-16
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Package:** `dsh-plugin-reactive-resume`
|
||||
|
||||
> **Post-implementation note:** the `tools` config key and `ctx.tools.restrict()` call described below (Config, `apply()` step 2, "Tool-name generation and drift detection") were cut from v0.1.0. `docs/spikes/2026-08-16-restrict-semantics.md` found that `restrict()` cannot reach tools a plugin's own `ctx.plugin(mcpClient, …)` registers — it throws when called from an unscoped plugin context, and even from a real agent scope it refuses to touch a scope's own (as opposed to inherited) registrations. There is no arrangement reachable from this plugin's `apply(ctx, config)` where curation works, so 0.1.0 ships all tools unfiltered, per that spike's documented fallback. The rest of this document is left as originally written for history; it does not describe what shipped.
|
||||
|
||||
## Goal
|
||||
|
||||
Distribute a DeepSeek Harness plugin that connects Harness to a Reactive Resume account, so a DSH user can read, create, and edit resumes and job applications from their agent session with one config row and an API key.
|
||||
|
||||
## Context
|
||||
|
||||
### What Reactive Resume already ships
|
||||
|
||||
Reactive Resume exposes a complete remote MCP server. Nothing on the server side needs to change for this plugin to work.
|
||||
|
||||
- `packages/mcp` registers 33 tools (`list_resumes`, `read_resume`, `apply_resume_patch`, `tailor_resume_for_application`, …), 3 prompts, and 2 resources.
|
||||
- `apps/server/src/http/app.ts` mounts `/mcp` and `/mcp/*` (Streamable HTTP) plus `/.well-known/mcp/server-card.json` (SEP-1649).
|
||||
- `apps/server/src/mcp/auth.ts` accepts either an OAuth `Authorization: Bearer` token or an `x-api-key` header. The API key path needs no interactive flow.
|
||||
- The web app already ships API key management at `/dashboard/settings/api-keys`, so user provisioning is a solved problem.
|
||||
|
||||
### What DeepSeek Harness provides
|
||||
|
||||
Verified against the published packages, not only the docs.
|
||||
|
||||
- A plugin is a TypeScript module exporting `name`, optional `inject`, and `apply(ctx, config)`. Config schemas use `@deepseek-ai/schemastery`.
|
||||
- `@deepseek-ai/dsh-mcp-client` (v0.0.1-rc.1) bridges one external MCP server per plugin instance. Its `StreamableHttpConfig` is exactly:
|
||||
|
||||
```ts
|
||||
interface StreamableHttpConfig {
|
||||
transport: 'streamable-http'
|
||||
serverName: string // [A-Za-z0-9_-]{1,32}, unique across live instances
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
toolCallTimeoutMs: number
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Bridged tools become model-facing as `mcp__<serverName>__<rawName>`.
|
||||
- `ctx.tools` is a `ToolRegistry` exposing `restrict(filter: ToolRestriction): () => void`, where `ToolRestriction` is `{ allow?: readonly string[]; deny?: readonly string[] }`.
|
||||
- `ctx.systemPrompt` (from `@deepseek-ai/dsh-system-prompt`) exposes `section(section: PromptSection): () => void`, where `PromptSection` is `{ name, order, text, complete? }`. Convention: `-100` is harness identity, `0` the deployment persona, and `100–199` is tool guidance.
|
||||
- Plugins are distributed on npm and discovered through the `dsh-plugin` GitHub topic.
|
||||
|
||||
### The gap this plugin fills
|
||||
|
||||
`dsh-mcp-client` has no tool filtering and no way to contribute prompt text. A user pasting a raw MCP row into `cordis.yml` gets all 33 tool schemas in their context budget and no guidance on Reactive Resume's JSON Patch semantics. Those two things are the plugin's reason to exist.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Scope | Thin MCP bridge + prompt section | The tool layer already exists and is maintained in Reactive Resume. Reimplementing 33 tool contracts against oRPC would drift every release. |
|
||||
| Repo | Standalone, outside the Reactive Resume monorepo | The plugin imports nothing from Reactive Resume — it speaks HTTP. The monorepo has no npm publish pipeline: root and every package are `private: true`, there is no build output, no changesets, no `NPM_TOKEN`, and no publish workflow. Adding one to ship a dependency-free package is cost without benefit. |
|
||||
| Auth | `x-api-key` only | Two clicks in Reactive Resume settings. OAuth needs a token store and an interactive flow for no gain. |
|
||||
| Drift protection | Server-card contract test in CI | Replaces the lockstep the monorepo would have given, without the pipeline. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package shape
|
||||
|
||||
Namespace plugin, mirroring `dsh-mcp-client`'s own export form:
|
||||
|
||||
```ts
|
||||
export const name = 'reactive-resume'
|
||||
export const inject = ['tools', 'systemPrompt']
|
||||
export const Config: z<Config>
|
||||
export async function apply(ctx: Context, config: Config): Promise<void>
|
||||
```
|
||||
|
||||
`@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `@deepseek-ai/dsh-mcp-client` are `peerDependencies` so the plugin binds to the host's versions rather than installing a second copy of the runtime.
|
||||
|
||||
### Config
|
||||
|
||||
```ts
|
||||
interface Config {
|
||||
/** API key from <url>/dashboard/settings/api-keys. */
|
||||
apiKey: string
|
||||
/** Reactive Resume instance origin. Default 'https://rxresu.me'. */
|
||||
url?: string
|
||||
/** Tool namespace: tools appear as mcp__<serverName>__<rawName>. Default 'resume'. */
|
||||
serverName?: string
|
||||
/** Which tool group to expose. Default 'all'. */
|
||||
tools?: 'resume' | 'applications' | 'all'
|
||||
/** Per-tool-call timeout. Default inherited from dsh-mcp-client. */
|
||||
toolCallTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
`url` accepts any origin so self-hosted instances work unchanged.
|
||||
|
||||
### apply()
|
||||
|
||||
Three steps, in order:
|
||||
|
||||
1. **Mount the bridge.** `ctx.plugin(mcpClient, { transport: 'streamable-http', serverName, url:`${url}/mcp`, headers: { 'x-api-key': apiKey }, toolCallTimeoutMs, failOnStartupError: true })`. `failOnStartupError: true` turns a bad API key into a loud activation failure instead of tools that silently fail at call time.
|
||||
|
||||
2. **Curate the tool surface.** When `tools !== 'all'`, call `ctx.tools.restrict({ deny: [...] })` with the namespaced names of the excluded group, computed from the generated tool-name list. See "Open risk" below.
|
||||
|
||||
3. **Contribute prompt guidance.** `ctx.systemPrompt.section({ name: 'reactive-resume', order: 150, text: PATCH_GUIDE })`.
|
||||
|
||||
All three return disposers; Cordis effect scoping unwinds them on unload, so no manual cleanup is needed beyond returning them where the API expects it.
|
||||
|
||||
### PATCH_GUIDE
|
||||
|
||||
The plugin's substance. Roughly 40 lines covering the failure modes Reactive Resume already encodes as error hints in `packages/mcp/src/tools.ts` (`errorHint`) — those hints exist precisely because models get these wrong:
|
||||
|
||||
- Call `read_resume` before `apply_resume_patch`; never patch blind.
|
||||
- `apply_resume_patch` takes RFC-6902 operations against the resume data document.
|
||||
- Fetch the `resume://_meta/schema` resource before constructing paths.
|
||||
- Section entries are arrays of objects keyed by UUID; ids are not indices.
|
||||
- A locked resume rejects writes — call `unlock_resume` first.
|
||||
- `list_resumes` is the way to recover a valid id after a 404.
|
||||
|
||||
Written as static text, not a provider function — it does not vary per assembly.
|
||||
|
||||
### Tool-name generation and drift detection
|
||||
|
||||
> **Superseded by the move into the monorepo.** The plugin now sits beside the MCP server it bridges, so `src/tool-names.test.ts` reads `@reactive-resume/mcp/tool-names` directly. A tool rename fails that test on the same pull request. The generated snapshot, the fetch script, and the scheduled network job described below no longer exist.
|
||||
|
||||
- Build step fetches `<url>/.well-known/mcp/server-card.json` and emits `src/tool-names.generated.ts` containing the raw tool names split into the `resume` and `applications` groups.
|
||||
- The generated file is committed, so `npm install` never needs network access.
|
||||
- A scheduled CI job refetches from `https://rxresu.me` and fails when the committed list no longer matches the live card. That failure is the signal to cut a new plugin release.
|
||||
|
||||
### Install UX
|
||||
|
||||
README's copy-paste block:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: reactive-resume
|
||||
name: dsh-plugin-reactive-resume
|
||||
config:
|
||||
apiKey: !!js process.env.RXRESUME_API_KEY
|
||||
```
|
||||
|
||||
## Open risk
|
||||
|
||||
`ToolRegistry.restrict`'s contract reads: *"Per-scope filter over the tools a scope INHERITS — the global layer and every ancestor layer on its chain. Restrictions intersect, and do not affect the scope's own registrations."*
|
||||
|
||||
`ctx.plugin(mcpClient, …)` mounts the bridge in a **child** scope of the plugin's context. The bridged tools are therefore registered in a descendant, not an ancestor, of the scope calling `restrict`. Whether a parent-scope restriction reaches them is unverified.
|
||||
|
||||
This must be settled by a spike before the config surface is committed, because `tools` is a public config key and removing it later is a breaking change.
|
||||
|
||||
**Fallback if `restrict` does not reach the child scope:** ship 0.1.0 without the `tools` key (equivalent to `'all'`), and solve curation in 0.2.0 — possibly by mounting the bridge at the same scope level rather than as a child. The prompt section alone justifies the release.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Spike (blocking):** run a local Reactive Resume (`dotenvx run -f .env.local -- pnpm dev`, port 3000), point a scratch Harness config at `http://localhost:3000/mcp` with an `x-api-key`, and confirm (a) the Streamable HTTP bridge connects and lists 33 tools, and (b) whether `ctx.tools.restrict` from the plugin's scope hides bridged tools.
|
||||
- **Unit:** `Config` schema defaults and validation; the deny-list computation from the generated tool names; `PATCH_GUIDE` section registers at order 150 with the expected name.
|
||||
- **Contract:** server-card fetch matches `src/tool-names.generated.ts`.
|
||||
- **Manual smoke before publish:** in a real Harness session against rxresu.me — list resumes, read one, apply a patch, verify the change in the web UI.
|
||||
|
||||
## Out of scope
|
||||
|
||||
OAuth support, `ctx.commands` shortcuts, a bundled Harness skill, local resume caching, and PDF rendering inside Harness. Each is additive and none blocks a useful 0.1.0.
|
||||
|
||||
## Build order
|
||||
|
||||
0. Spike `restrict()` semantics and the Streamable HTTP bridge against localhost.
|
||||
1. Repo scaffold, `Config` schema, bridge mount, README. End-to-end working install.
|
||||
2. `PATCH_GUIDE` prompt section.
|
||||
3. Tool profiles, generated name list, drift CI.
|
||||
4. Publish 0.1.0, tag the repo with the `dsh-plugin` topic, submit to `awesome-deepseek-harness`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
# Spike: does `ctx.tools.restrict()` reach a child scope's tools?
|
||||
|
||||
**Verdict up front:** `restrict reaches child-scope tools: NO`
|
||||
|
||||
The plugin's own `apply(ctx, config)` — a bare Cordis plugin context that
|
||||
never mints a `dsh-scope` scope — cannot call `ctx.tools.restrict()` at all.
|
||||
It throws. When the arrangement is fixed so `restrict()` is at least
|
||||
callable (a real agent-style scope), it still refuses to touch a tool the
|
||||
bridge registered inside that same scope's own layer. Neither path reaches
|
||||
the topology this plugin needs. The `tools` config key must not ship in
|
||||
0.1.0.
|
||||
|
||||
## Environment and bootstrapping
|
||||
|
||||
Scaffolded a throwaway workspace at `/tmp/dsh-spike` (deleted after this
|
||||
spike; nothing there is part of the plugin repo).
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/dsh-spike && cd /tmp/dsh-spike && pnpm init
|
||||
```
|
||||
|
||||
**Version resolution problem.** `pnpm add @deepseek-ai/cordis @deepseek-ai/dsh-tools @deepseek-ai/schemastery`
|
||||
with no version pins resolves `dsh-tools` to its `next` dist-tag
|
||||
(`0.1.0-rc.6`), whose peer chain requires `@deepseek-ai/dsh-agent` and
|
||||
`@deepseek-ai/dsh-session`, both of which peer-depend on
|
||||
`@deepseek-ai/dsh-type-meta` — a package that returns a plain 404 from the
|
||||
public npm registry (confirmed directly: `npm view @deepseek-ai/dsh-type-meta`
|
||||
→ `404 Not Found`). Pinning `dsh-tools` to `0.0.1-rc.1` (the version this
|
||||
plugin actually targets — confirmed via `npm view @deepseek-ai/dsh-tools
|
||||
dist-tags`, where `latest` is `0.0.1-rc.1`) does not by itself fix this: pnpm
|
||||
still auto-installs peer dependencies for the lockfile, so the same
|
||||
`dsh-type-meta` 404 recurs indirectly through `dsh-agent`'s and
|
||||
`dsh-session`'s peer graph.
|
||||
|
||||
**Fix that worked:** disable pnpm's peer auto-install and add only the
|
||||
packages `dsh-tools`'s *compiled* `lib/index.js` actually imports at
|
||||
runtime (checked directly — `import type` lines don't need the package on
|
||||
disk, real `import` lines do):
|
||||
|
||||
```
|
||||
import { Service } from "@deepseek-ai/cordis";
|
||||
import z from "@deepseek-ai/schemastery";
|
||||
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from "@deepseek-ai/dsh-scope";
|
||||
import { CallId, HarnessError, assertNever, deepFreeze } from "@deepseek-ai/dsh-llm";
|
||||
import { isJsonValue, snapshotJsonValue } from "@deepseek-ai/dsh-session";
|
||||
```
|
||||
|
||||
`.npmrc`:
|
||||
|
||||
```
|
||||
auto-install-peers=false
|
||||
strict-peer-dependencies=false
|
||||
```
|
||||
|
||||
Install commands, in order (each added only after the previous run's
|
||||
`ERR_MODULE_NOT_FOUND` named the next missing runtime import):
|
||||
|
||||
```bash
|
||||
pnpm add @deepseek-ai/cordis@^4.0.1-rc.1 @deepseek-ai/dsh-tools@0.0.1-rc.1 @deepseek-ai/schemastery@^3.18.1-rc.1 \
|
||||
--config.auto-install-peers=false --config.strict-peer-dependencies=false
|
||||
pnpm add @deepseek-ai/dsh-scope@0.0.1-rc.1 @deepseek-ai/dsh-llm@0.0.1-rc.1 @deepseek-ai/dsh-session@0.0.1-rc.1 \
|
||||
--config.auto-install-peers=false --config.strict-peer-dependencies=false
|
||||
pnpm add @deepseek-ai/dsh-system-prompt@0.0.1-rc.1 @deepseek-ai/dsh-invariants@0.0.1-rc.1 \
|
||||
--config.auto-install-peers=false --config.strict-peer-dependencies=false
|
||||
pnpm add @deepseek-ai/dsh-timeout@0.0.1-rc.1 \
|
||||
--config.auto-install-peers=false --config.strict-peer-dependencies=false
|
||||
```
|
||||
|
||||
`dsh-system-prompt` and `dsh-invariants` were needed for a second reason,
|
||||
not just a `dsh-tools` runtime import: `ToolRegistry.inject = ["systemPrompt"]`
|
||||
and its constructor calls `ctx.systemPrompt.tools(...)` immediately, so a
|
||||
`systemPrompt` service must be mounted on `ctx` *before* `ToolRegistry` is.
|
||||
`dsh-timeout` surfaced one level further down, as a real (non-type) import
|
||||
inside `dsh-llm`'s compiled output.
|
||||
|
||||
None of the packages actually needed at `0.0.1-rc.1` depend on
|
||||
`dsh-type-meta` — only `dsh-agent` and `dsh-session`'s *peer* list does
|
||||
(`dsh-session` doesn't import it at runtime, so it was never installed and
|
||||
never missed). Final resolved set: `cordis@4.0.1`, `dsh-tools@0.0.1-rc.1`,
|
||||
`schemastery@3.18.1`, `dsh-scope@0.0.1-rc.1`, `dsh-llm@0.0.1-rc.1`,
|
||||
`dsh-session@0.0.1-rc.1`, `dsh-system-prompt@0.0.1-rc.1`,
|
||||
`dsh-invariants@0.0.1-rc.1`, `dsh-timeout@0.0.1-rc.1`.
|
||||
|
||||
**Dead end worth recording:** before finding the npm-registry version-pin
|
||||
fix, I found a fully-resolved install of these packages already on disk at
|
||||
`~/.local/share/mise/installs/node/24.19.0/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/*`
|
||||
(the globally-installed `dsh` CLI's own bundled `node_modules`). That
|
||||
install is `dsh-tools@0.1.0-rc.6`, not `0.0.1-rc.1` — a different minor
|
||||
line with a renamed class (`ToolRuntime`, not `ToolRegistry`) and a
|
||||
different `register`/`restrict`/`schemas` signature (`scope?: ScopeKey`
|
||||
parameter instead of implicit calling-context resolution). I did **not**
|
||||
use this install for the verdict below — it's the wrong pinned version for
|
||||
this plugin — but it's why the bootstrapping path above took several
|
||||
iterations: I initially assumed the API surface from that install, then had
|
||||
to re-derive it from the actually-pinned `0.0.1-rc.1` types.
|
||||
|
||||
## The actual `ToolDefinition` shape (0.0.1-rc.1)
|
||||
|
||||
The brief's guessed shape (`parameters`, bare `async execute() { return
|
||||
{content:[...]} }`) doesn't compile against `node_modules/@deepseek-ai/dsh-tools/lib/types/index.d.ts`.
|
||||
The real shape:
|
||||
|
||||
```ts
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
// ToolSchema = { name: string; description: string; parameters: Record<string, unknown> }
|
||||
readonly output: ToolOutputDefinition; // MANDATORY, not optional
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<unknown>; // returns the canonical value, not ContentBlock[]
|
||||
finalizeContent?(...): ContentBlock[] | undefined;
|
||||
timeoutMs?: number;
|
||||
isConcurrencySafe?(args: unknown): boolean;
|
||||
presentCall?(args: unknown): ToolCallView | undefined;
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;
|
||||
}
|
||||
interface ToolOutputDefinition {
|
||||
readonly schema: JsonSchemaNode; // enforced JSON Schema subset
|
||||
render(args: unknown, value: JsonValue): ContentBlock[]; // projects the canonical value to model-facing content
|
||||
presentationMeta?(args: unknown, value: JsonValue): JsonValue;
|
||||
}
|
||||
```
|
||||
|
||||
`execute()` returns the tool's canonical JSON value (validated against
|
||||
`output.schema`); `output.render()` is what turns that value into
|
||||
`ContentBlock[]`. `register()` throws a `TypeError` if `output` is missing
|
||||
or `output.render` isn't a function — confirmed by reading
|
||||
`ToolRegistry.register` in the compiled `lib/index.js`.
|
||||
|
||||
`ToolRegistry` itself: `export { ToolRegistry, ToolRegistry as default }` —
|
||||
it's a Cordis `Service` subclass (`super(ctx, "tools")`), so it's mounted
|
||||
with `ctx.plugin(ToolRegistry, config)`, not `ctx.plugin(tools)` where
|
||||
`tools` is the whole module namespace (the brief's guess).
|
||||
|
||||
## The mechanism (read from the compiled source, then verified by running it)
|
||||
|
||||
`declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } }`
|
||||
— `ctx.tools` is one Cordis **service singleton**, shared down the whole
|
||||
context tree exactly like every other Cordis service. There is no
|
||||
per-Cordis-child-context instance of the registry; nesting a plugin under
|
||||
`ctx.plugin(...)` does not give it a private `tools`.
|
||||
|
||||
What actually gates `register()`/`restrict()`'s visibility isn't the Cordis
|
||||
plugin-context tree at all — it's a **separate, opt-in scoping layer** from
|
||||
`@deepseek-ai/dsh-scope`:
|
||||
|
||||
- `scopeOf(ctx)` reads "the nearest scope tag inherited by a context" — and
|
||||
a context only carries a scope tag if something called
|
||||
`createScope(ctx, key)` on it (or a Cordis ancestor of it). A plain
|
||||
`ctx.plugin(child)` context is **not** scoped by that call alone.
|
||||
- `ToolRegistry.register(definition)`: `this.layers.effect(this.ctx, (layer) => layer.tools.insert(name, definition), ...)` —
|
||||
lands in whatever layer `scopeOf(this.ctx)` resolves to (the global layer
|
||||
if unscoped). No scope requirement to call it.
|
||||
- `ToolRegistry.restrict(filter)`: the **first line** is
|
||||
`const scope = scopeOf(this.ctx); if (scope === void 0) throw new Error("tools.restrict() requires a scoped context (agent.ctx): ...")`.
|
||||
It is unconditionally unusable from an unscoped context — this is not a
|
||||
silent no-op, it's a thrown error.
|
||||
- Even when `scope !== undefined`, `restrict()` computes
|
||||
`known = this.view(scope).restrictableNames` (the scope's *inherited*
|
||||
surface — global + ancestor layers) and rejects any name not in that set:
|
||||
`"a restriction filters what this scope inherits, never what it registers itself"`.
|
||||
A tool registered as a **child** of the exact scope doing the restricting
|
||||
is, by construction, in that scope's own layer, not its inherited surface
|
||||
— so it is unconditionally unreachable by that scope's own `restrict()`
|
||||
call, confirmed by the second probe below.
|
||||
|
||||
This is a stricter, more mechanical version of what the `ToolRestriction`
|
||||
docstring already said in prose (`"do not affect the scope's own
|
||||
registrations"`) — the two probes below hit it from two different angles
|
||||
and got two different thrown errors, not one graceful no-op.
|
||||
|
||||
## Probe 1 — the literal topology from the task brief
|
||||
|
||||
`ctx.plugin(mcpClient)` mounts the bridge as a Cordis **child** of the
|
||||
plugin's own `apply(ctx, config)`; the plugin then calls
|
||||
`ctx.tools.restrict(...)` from its own (parent, unscoped) `ctx`. This
|
||||
reproduces the plugin's real design as literally as a stub allows.
|
||||
|
||||
`/tmp/dsh-spike/probe.ts`:
|
||||
|
||||
```ts
|
||||
import { Context } from "@deepseek-ai/cordis";
|
||||
import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
|
||||
import ToolRegistry from "@deepseek-ai/dsh-tools";
|
||||
|
||||
const root = new Context();
|
||||
|
||||
// ToolRegistry.inject = ["systemPrompt"], and its constructor calls
|
||||
// ctx.systemPrompt.tools(...) immediately, so systemPrompt must be mounted first.
|
||||
await root.plugin(SystemPrompt);
|
||||
await root.plugin(ToolRegistry);
|
||||
|
||||
/** Stands in for dsh-mcp-client: registers one tool in whatever scope loads it. */
|
||||
const stubBridge = {
|
||||
name: "stub-bridge",
|
||||
inject: ["tools"],
|
||||
apply(ctx: Context) {
|
||||
ctx.tools.register({
|
||||
name: "mcp__resume__list_applications",
|
||||
description: "stub",
|
||||
parameters: { type: "object", properties: {} },
|
||||
output: {
|
||||
schema: { type: "string" },
|
||||
render: (_args: unknown, value: unknown) => [{ type: "text", text: String(value) }],
|
||||
},
|
||||
async execute() {
|
||||
return "ok";
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// The plugin under design mounts the bridge as a child, exactly like this.
|
||||
await root.plugin(stubBridge);
|
||||
|
||||
const names = () => root.tools.schemas().map((s) => s.name);
|
||||
console.log("BEFORE", names());
|
||||
|
||||
try {
|
||||
const dispose = root.tools.restrict({ deny: ["mcp__resume__list_applications"] });
|
||||
console.log("AFTER", names());
|
||||
dispose();
|
||||
console.log("DISPOSED", names());
|
||||
} catch (err) {
|
||||
console.log("RESTRICT_THREW", (err as Error).message);
|
||||
}
|
||||
```
|
||||
|
||||
Run with `node --experimental-strip-types probe.ts`. Actual output,
|
||||
verbatim:
|
||||
|
||||
```
|
||||
BEFORE [ 'mcp__resume__list_applications' ]
|
||||
RESTRICT_THREW tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead
|
||||
```
|
||||
|
||||
`restrict()` never gets a chance to filter anything — it throws before
|
||||
touching the tool set, because `root` (and every context under it, absent
|
||||
an explicit `createScope()`) is unscoped.
|
||||
|
||||
## Probe 2 (Step 4) — the best-case sibling arrangement
|
||||
|
||||
Per the brief's Step 4, tried fixing the exception by giving the plugin a
|
||||
real `dsh-scope` scope (what `createScope()` provides) and calling
|
||||
`restrict()` from *inside* that scope, matching "registering the
|
||||
restriction inside the same scope the bridge loads into."
|
||||
|
||||
Where a real agent gets this: `@deepseek-ai/dsh-agent-loop@0.0.1-rc.1`
|
||||
`lib/index.js:375` —
|
||||
|
||||
```js
|
||||
this.scope = createScope(loopCtx, this);
|
||||
this.ctx = this.scope.ctx.extend({ agent: this });
|
||||
```
|
||||
|
||||
— **not** `dsh-agent`, which never calls `createScope` (its compiled
|
||||
`lib/index.js` only calls `scopeTarget`, for event routing, not for minting
|
||||
a scope). This is the exact line that builds `agent.ctx` — the thing
|
||||
`ToolRegistry`'s thrown error message names as what `restrict()` requires.
|
||||
Confirmed directly: installed both `dsh-agent@0.0.1-rc.1` and
|
||||
`dsh-agent-loop@0.0.1-rc.1` with the same
|
||||
`--config.auto-install-peers=false --config.strict-peer-dependencies=false`
|
||||
workaround used for the rest of the dependency tree (`dsh-type-meta` is only
|
||||
a *peer* dependency of `dsh-agent`, never a runtime import — same situation
|
||||
as `dsh-session`/`dsh-scope` above — so it's never actually needed on disk),
|
||||
then grepped the installed `lib/index.js` files for `createScope`.
|
||||
|
||||
`/tmp/dsh-spike/probe2.ts`:
|
||||
|
||||
```ts
|
||||
import { Context } from "@deepseek-ai/cordis";
|
||||
import { createScope } from "@deepseek-ai/dsh-scope";
|
||||
import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
|
||||
import ToolRegistry from "@deepseek-ai/dsh-tools";
|
||||
|
||||
const root = new Context();
|
||||
await root.plugin(SystemPrompt);
|
||||
await root.plugin(ToolRegistry);
|
||||
|
||||
const stubBridge = {
|
||||
name: "stub-bridge",
|
||||
inject: ["tools"],
|
||||
apply(ctx: Context) {
|
||||
ctx.tools.register({
|
||||
name: "mcp__resume__list_applications",
|
||||
description: "stub",
|
||||
parameters: { type: "object", properties: {} },
|
||||
output: {
|
||||
schema: { type: "string" },
|
||||
render: (_args: unknown, value: unknown) => [{ type: "text", text: String(value) }],
|
||||
},
|
||||
async execute() {
|
||||
return "ok";
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Mint a dsh-scope "Scope" (what dsh-agent-loop does to build agent.ctx --
|
||||
// see lib/index.js:375) and mount the bridge as a CHILD of that scope's ctx
|
||||
// -- i.e. an inherited/ancestor registration relative to the scope itself,
|
||||
// not the scope's own layer.
|
||||
const scopeKey = {};
|
||||
const scope = createScope(root, scopeKey);
|
||||
await scope.ctx.plugin(stubBridge);
|
||||
|
||||
const namesFor = (ctx: Context) => ctx.tools.schemas(scopeKey).map((s) => s.name);
|
||||
console.log("BEFORE(scoped view)", namesFor(root));
|
||||
|
||||
// Cordis requires a plugin to declare inject: ['tools'] to bare-access
|
||||
// ctx.tools; call restrict() from inside a plugin mounted on scope.ctx so
|
||||
// the "calling scope" Cordis sees is the scope itself (agent.ctx-equivalent).
|
||||
let disposeRestrict: (() => void) | undefined;
|
||||
const restrictor = {
|
||||
name: "stub-restrictor",
|
||||
inject: ["tools"],
|
||||
apply(ctx: Context) {
|
||||
try {
|
||||
disposeRestrict = ctx.tools.restrict({ deny: ["mcp__resume__list_applications"] });
|
||||
console.log("AFTER(scoped view)", namesFor(root));
|
||||
} catch (err) {
|
||||
console.log("RESTRICT_THREW", (err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
await scope.ctx.plugin(restrictor);
|
||||
|
||||
if (disposeRestrict) {
|
||||
disposeRestrict();
|
||||
console.log("DISPOSED(scoped view)", namesFor(root));
|
||||
}
|
||||
```
|
||||
|
||||
Run with `node --experimental-strip-types probe2.ts`. Actual output,
|
||||
verbatim:
|
||||
|
||||
```
|
||||
BEFORE(scoped view) [ 'mcp__resume__list_applications' ]
|
||||
RESTRICT_THREW tools.restrict() names unknown inherited tool "mcp__resume__list_applications"; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: (none)
|
||||
```
|
||||
|
||||
`restrict()` is now at least *callable*, but it explicitly refuses: the
|
||||
bridge's tool lives in the same scope's own layer (it was mounted as a
|
||||
Cordis child of `scope.ctx`, which is what makes it inherit that scope
|
||||
tag), and `restrict()`'s error message says outright that it will never
|
||||
touch a scope's own registrations, only what it inherits from an ancestor.
|
||||
`Restrictable tools: (none)` — there was nothing in this arrangement for
|
||||
the scope to restrict, because nothing was registered in any ancestor of
|
||||
it.
|
||||
|
||||
I did not chase the remaining permutation (bridge registered as an
|
||||
*ancestor* scope's own layer, restrict called from a *descendant* scope of
|
||||
that ancestor) — the class-level doc comment in `dsh-tools` confirms that
|
||||
shape is the one `restrict()` is actually built for (a parent scope curbing
|
||||
what a child scope inherits from it), but it doesn't match this plugin's
|
||||
topology: the plugin's `apply(ctx, config)` runs once at harness startup,
|
||||
before any real agent scope exists, and isn't in a position to be an
|
||||
ancestor of the eventual agent's scope. Chasing it further would still not
|
||||
produce an arrangement reachable from this plugin's own `apply(ctx, config)`,
|
||||
which is the brief's actual bar for flipping the verdict to YES. Time spent
|
||||
on Step 4: about 15 minutes, well under the 30-minute cap.
|
||||
|
||||
I did not spin up a full agent loop from `dsh-agent`/`dsh-agent-loop` (the
|
||||
stub plugins above stand in for one, per the brief's instruction not to need
|
||||
a live agent) — but I did install both packages and grep their compiled
|
||||
output to confirm the `createScope()` attribution above, as noted earlier in
|
||||
this section. No residual gap remains on that point.
|
||||
|
||||
## Verdict
|
||||
|
||||
```
|
||||
restrict reaches child-scope tools: NO
|
||||
```
|
||||
|
||||
Both the literal plugin topology (unscoped `restrict()` call → throws
|
||||
immediately) and the best-case fix for that (scoped `restrict()` call →
|
||||
throws with "unknown inherited tool" because the bridge's registration is
|
||||
the scope's own, not inherited) fail to hide the child-registered tool. The
|
||||
`tools` config key is deferred out of 0.1.0, per Task 6's fallback note.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
rm -rf /tmp/dsh-spike
|
||||
```
|
||||
Reference in New Issue
Block a user