208 lines
9.5 KiB
Markdown
208 lines
9.5 KiB
Markdown
# dsh-facet
|
|
|
|
Scope an agent session to **one facet of a system** — the folder the
|
|
conversation was started in, plus whatever you explicitly grant it.
|
|
|
|
A DeepSeek Harness bundle: one host row (the policy) and one agent preset (the
|
|
enforcement).
|
|
|
|
---
|
|
|
|
## Why this exists
|
|
|
|
A multi-faceted system is built from modules that look alike. In the project this
|
|
was written against there are fourteen controllers — the same idioms, the same
|
|
naming, the same shape — and a conversation about one of them that can see all
|
|
fourteen spends its first several steps working out which one it is looking at.
|
|
That is a quality problem before it is a cost problem, and the cost is real too:
|
|
in a long session the dominant expense is re-reading accumulated history, and
|
|
most of that history is file content the agent did not need.
|
|
|
|
So this narrows the world to one module rather than trying to steer through all
|
|
of them. It is the same idea as the `focus` argument in
|
|
[`dsh-recall`](https://github.com/green-dalii/dsh-recall) — which confines a
|
|
*search* to one module — applied to the whole agent: what it may read, where it
|
|
may search, and what its prompt says it is.
|
|
|
|
## What it does
|
|
|
|
Three things, and they are deliberately in three different places:
|
|
|
|
| | where | why there |
|
|
|---|---|---|
|
|
| **the rule** ("work inside the facet; ask to go outside") | system prompt | invariant for the session, so it does not invalidate the prompt cache |
|
|
| **the binding** (which folder, what is granted) | runtime-context snapshot | it changes when you grant something, and a changed system prompt costs a full-context cache miss |
|
|
| **the guard** | `tools/pre-execute` | the part that does not depend on the model agreeing |
|
|
|
|
**The scope is not configured — it is where you already are.** A session's
|
|
working directory is recorded immutably when the session is created, so starting
|
|
a conversation in `src/controllers/room/occupancy` makes that controller the
|
|
facet, and starting one at the project root simply leaves the session unscoped
|
|
(which is the honest answer for a conversation that is genuinely about
|
|
everything).
|
|
|
|
**Going outside is a question, not a refusal.** In that same project 41.6% of the
|
|
code — shared types, services, infrastructure — sits outside every controller. A
|
|
wall that cannot be opened would not confine the agent, it would only make it
|
|
wrong, and an agent that cannot reach a shared type does not stop needing it.
|
|
So an out-of-scope file call is put to you, and saying yes records the grant for
|
|
the rest of the conversation.
|
|
|
|
### The `facet` tool
|
|
|
|
| action | effect |
|
|
|---|---|
|
|
| `status` | the root, the mode, the standing grants and this session's grants |
|
|
| `add` | asks you to approve an extra path, then records it for this session |
|
|
| `remove` | drops a session grant |
|
|
| `reset` | returns to the session's own folder |
|
|
|
|
`add` is the only one that needs approval. It is also asked in `deny` mode,
|
|
because otherwise there would be no way to grant anything at all.
|
|
|
|
## Install
|
|
|
|
```sh
|
|
dsh plugin --profile web add "/path/to/dsh-facet"
|
|
dsh --profile web --dump-config | grep preset-facet # expect the row
|
|
```
|
|
|
|
The bundle contributes two layers:
|
|
|
|
- **`cordis.patch.yml`** — the host row `facet-policy`, which owns the mode and
|
|
the standing grants and publishes them as `ctx.facetPolicy`.
|
|
- **`presets/facet.patch.yml`** — the agent preset `facet`, which is the shipped
|
|
`standard` composition plus the guard and a configured compaction row.
|
|
|
|
Then pick **Facet** as the preset for a session — in the Web UI that is the
|
|
preset picker in the new-session surface, or *Settings → General → Agent preset*
|
|
to make it the default for new sessions. The preset is fixed once a session's
|
|
first turn has run, so choose it when you start the conversation.
|
|
|
|
### Configure it in your profile, not in the package
|
|
|
|
The host row's config is where this deployment's facts belong — the profile's own
|
|
`cordis.patch.yml`:
|
|
|
|
```yaml
|
|
- id: facet-policy
|
|
name: dsh-facet
|
|
config:
|
|
mode: ask # ask | deny | off
|
|
extraRoots: # always in scope, in every session
|
|
- ../../shared # relative to each session's own directory
|
|
```
|
|
|
|
`extraRoots` is for code a module legitimately always depends on. Anything you
|
|
grant by hand is per-conversation and is never persisted — "this session may
|
|
also see `src/shared`" is a statement about one conversation, and writing it down
|
|
would quietly widen every future one, which is the failure this plugin exists to
|
|
prevent.
|
|
|
|
## The compaction row
|
|
|
|
`presets/facet.patch.yml` also configures `compaction-basic`, because a scoped
|
|
session and a budgeted context are two halves of one idea — scoping reduces
|
|
context at the source, compaction is the ceiling:
|
|
|
|
```yaml
|
|
thresholdRatio: 0.35 # a 350k ceiling, not the shipped 800k
|
|
retainTokens: 80000 # an absolute working set, not 16% of a 1M window
|
|
maxTokens: 16384 # the summarisation cap, stated rather than inherited
|
|
```
|
|
|
|
The shipped ratio is `0.80` of a 1,000,000-token window, i.e. 800k. Measured
|
|
across this deployment's 40 recorded sessions, the deepest ever reached 787,668
|
|
pressure tokens — **the trigger had never once fired**, so every token below it
|
|
was re-read on every subsequent step. `maxTokens` is worth stating explicitly:
|
|
its default is not 8192 but `headroomTokens`, 65536.
|
|
|
|
If you want that budget on *every* session rather than only scoped ones,
|
|
`presets/standard-compaction.patch.yml` is an opt-in overlay that applies the
|
|
same row to the shipped `standard` preset. It is not in the bundle's default
|
|
patch list, and the header of that file explains why: a preset declaration
|
|
replaces the whole plugin list rather than merging into it, so the file is a
|
|
frozen copy of `standard` that will not follow the harness when `standard`
|
|
changes.
|
|
|
|
## What it does not cover
|
|
|
|
Honest boundaries, because each one is a decision rather than an oversight:
|
|
|
|
- **`bash` is not guarded.** A shell command cannot be judged reliably from
|
|
argv, so the facet is enforced on the file tools and `bash` remains a
|
|
legitimate way out. The prompt tells the model this rather than pretending
|
|
otherwise.
|
|
- **Tools that name a path in a shape the table cannot read are not guessed at** —
|
|
including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of
|
|
tools whose path argument is a plain top-level string.
|
|
- **This guards the tool pipeline, not the filesystem.** There is no read seam in
|
|
the harness — `dsh-fs-sandbox` states plainly that "reads pass through
|
|
untouched: every mode permits reading" — so anything that reads through
|
|
`ctx.fs` without going through tool dispatch (another plugin, the HTTP
|
|
workspace-files endpoint) is unaffected. Fencing *all* readers would mean
|
|
replacing `ctx.fs` with a stricter `FileSystem` subclass.
|
|
- **A grant is not persisted**, so it does not survive a process restart.
|
|
- **Sessions at the project root are not scoped at all**, which is deliberate.
|
|
|
|
## What has been verified, and what has not
|
|
|
|
Verified:
|
|
|
|
- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard —
|
|
including that containment is decided on canonical targets (a symlink inside
|
|
the facet pointing outside it is refused), that a prefix is not a parent
|
|
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never
|
|
makes the guard throw, and that every `facet` result satisfies the tool's own
|
|
declared output schema.
|
|
- The bundle's patch layers parse, merge and land correctly against the real
|
|
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
|
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
|
the `facet-guard` row and the compaction config, with `preset-standard`
|
|
untouched. The optional `standard-compaction` overlay was checked the same way.
|
|
|
|
**Not yet verified: a live session.** The plugin has not been loaded by a running
|
|
harness, because that needs the bundle installed. The first thing to check after
|
|
installing is that `preset-facet` is not `broken` — a preset whose child row
|
|
fails to resolve still appears on the roster, with its diagnostic, and cannot
|
|
compose a session:
|
|
|
|
```sh
|
|
dsh --profile web --dump-config | grep -A 3 preset-facet
|
|
```
|
|
|
|
## Development
|
|
|
|
```sh
|
|
node scripts/dev-links.mjs # link the harness's cordis/schemastery/dsh-tools
|
|
npm test
|
|
```
|
|
|
|
`dev-links.mjs` links rather than installs those three packages, and for
|
|
`@deepseek-ai/cordis` that is a correctness requirement rather than tidiness: a
|
|
second copy of Cordis means a second `Context` class, two service registries in
|
|
one process, and a plugin whose services are invisible to the harness that loaded
|
|
it.
|
|
|
|
## Layout
|
|
|
|
```
|
|
index.js host row: the policy, published as ctx.facetPolicy
|
|
lib/policy.js the stateful half — scope resolution, grants
|
|
lib/scope.js the pure half — argument extraction, text, reasons
|
|
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
|
presets/facet.patch.yml the `facet` agent preset
|
|
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
|
test/ 42 tests
|
|
```
|
|
|
|
## Also in this repository
|
|
|
|
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
|
including the harness mechanisms it rests on.
|
|
- [`compaction-tuning.md`](../compaction-tuning.md) — the investigation into why
|
|
compaction had never fired. Its measurements reproduce exactly; note that its
|
|
"The change" section prescribes `$DSH_HOME/.agent-presets/`, a directory
|
|
**nothing reads any more** on the installed harness. Presets are bundle patch
|
|
declarations now, which is what `presets/facet.patch.yml` is.
|