refactor(scope,agent-presets): per-preset standing mounts over a scope parent chain

A preset is now ONE composition per process, not one per session. The roster
mounts it once under a synthetic standing scope; each agent joins by having
its scope key parented to the mount's. Two mechanisms in dsh-scope carry the
whole change: registration views walk the parent chain (global → preset →
agent, nearest shadowing farthest — ScopedLayers.chainLayers), and scoped
event dispatch admits a listener tagged with an ancestor of the carrier key,
which is what lets a standing composition's plan/compaction/token listeners
observe each agent composed under it while a sibling preset's stay deaf.

The preset plugins already key their state by Session/Agent — they predate
presets and were written for the shared world — so sharing one instance is a
return to their design, not a rewrite. Preset ymls are unchanged: one mount
per preset means one Entry per preset, whose entry-local realms keep two
presets' services apart exactly as they kept two sessions' apart before.

The standing scope hangs off the service's UNTRACED context (selfCtx): a
method invoked through the traceable proxy sees this.ctx rebound to the
caller and carrying its shadow, and a subtree minted from that resolves every
service through the shadow's fiber instead of each entry's own inject store —
preset rows then fail on the very services they declare.

A standing mount survives its agents deliberately. The composition a running
session joined must outlive the file changing or disappearing underneath it;
reclamation happens at whole-tree teardown, and file edits reach only future
generations (the authoring layer swaps the pointer, never disposes a joined
generation).
This commit is contained in:
Yichen Jiang
2026-08-08 17:51:49 +08:00
parent 2afdc68fab
commit e18aa2745c
8 changed files with 275 additions and 35 deletions

View File

@@ -950,11 +950,16 @@ export class ToolRegistry extends Service {
)
}
/** First monotonic denial from the global then matching scoped guard layers. */
/** First monotonic denial from the global then the scope chain's guard layers, farthest first. */
private guardReason(exec: ToolExecution): string | undefined {
const globalReason = this.layers.global.guardReason(exec)
if (globalReason !== undefined) return globalReason
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
if (exec.agent === undefined) return undefined
for (const layer of this.layers.chainLayers(exec.agent)) {
const reason = layer.guardReason(exec)
if (reason !== undefined) return reason
}
return undefined
}
/**
@@ -966,20 +971,26 @@ export class ToolRegistry extends Service {
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
const layer = this.layers.peek(scope)
// Scope-chain layers, farthest ancestor first, the exact scope last.
const layers = this.layers.chainLayers(scope)
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.layers.global.tools.entries()) {
knownNames.add(name)
restrictableNames.add(name)
if (layer?.admits(name) ?? true) visible.set(name, definition)
// Restrictions intersect across the whole chain: any scope on it may
// mask a global-surface name for everything nested inside it.
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer?.tools.entries() ?? []) {
knownNames.add(name)
visible.set(name, definition)
// Chain layers second, nearest last: same-name entries REPLACE (shadow)
// the global and farther-scope ones, and scope-local registrations are
// never part of the global filter above.
for (const layer of layers) {
for (const [name, definition] of layer.tools.entries()) {
knownNames.add(name)
visible.set(name, definition)
}
}
// Presentation infrastructure is resolved last and outside capability
// filtering. Registration rejects this reserved name, so the insertion is