Merge remote-tracking branch 'origin/master' into fix/windows-native-acl-coverage

This commit is contained in:
Huanqi Cao
2026-08-10 21:05:38 +08:00
49 changed files with 909 additions and 92 deletions
@@ -2,5 +2,5 @@
# 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/preset/agent-presets/README.md
README.md: 4f41361bc2f22ea87ecbd3e5699e119d8019cc16
README.zh.md: 148bb06cfcc1c469db7b590e5facb45c35b5fb6d
README.md: 632fc7828313a932512cb59a924050005067a008
README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899
+10
View File
@@ -14,6 +14,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason.
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row.
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved.
- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined).
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
@@ -27,6 +29,14 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions.
### Composing a child agent
A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections.
Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window.
The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default.
### Which preset a session runs
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
@@ -14,6 +14,8 @@
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。
- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
@@ -27,6 +29,14 @@
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。
### 组装子 agent
subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,绝不走 `mount()`。所有面向模型的行都在 agent 平面,工具注册表的全局层是空的,因此没有加入任何组装的子 agent 抵达模型时既没有任何工具,也没有父方的任何提示段。
按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。
子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。
### 会话实际运行的是哪个 preset
创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
+53 -3
View File
@@ -28,7 +28,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
import { mountPreset, serviceForAgent } from './mount.ts'
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
import { PresetExistsError } from './authoring.ts'
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts'
@@ -51,8 +51,8 @@ export {
METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata,
} from './metadata.ts'
export {
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
type PresetMount,
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor,
type JoinedPresetMount, type PresetMount,
} from './mount.ts'
export {
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
@@ -238,6 +238,56 @@ export class AgentPresets extends Service {
return preset
}
/**
* Join one agent to the SAME standing composition another already runs on.
*
* This is how a child agent inherits its parent's capabilities. It is a bind,
* not a mount: the parent's generation is already composed, so the child gets
* that exact instance — the same plugin objects, the same tool registrations,
* the same prompt sections. Re-resolving the parent's preset by id instead
* would re-read the roster, and a composition file edited since the parent
* started would hand the child a DIFFERENT generation than the one its
* parent's history was produced under (and a preset deleted since would fail
* the child outright while its parent keeps running).
*
* Synchronous, and with no composition failure mode of its own — it reads no
* roster, mounts nothing, and touches no file — which is what lets a child
* creation window use it: the two in-process subagent drivers compose their
* children inside a synchronous `setup`. It still rejects a caller error, as
* the `@throws` below record.
*
* A parent that joined no preset — a rosterless deployment — yields no join
* and no error: there, the model-facing rows sit in the host composition and
* the child already sees them through the global layer.
* @param agentCtx - the joining agent's scope context.
* @param parentCtx - the scope context of the agent whose composition to join.
* @returns the preset id joined, or undefined when the parent joined none.
* @throws when `agentCtx` carries no scope, or has already joined a preset.
*/
composeFrom(agentCtx: Context, parentCtx: Context): string | undefined {
const agentKey = scopeOf(agentCtx)
if (agentKey === undefined) {
throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset')
}
const standing = standingMountFor(parentCtx)
if (standing === undefined) return undefined
this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))
return standing.presetId
}
/**
* The preset one live agent runs on.
*
* Read from the live scope chain rather than from the session, so it answers
* for an agent whose session has not recorded a preset yet — a child agent
* whose durable header is being built from its parent's composition.
* @param agentCtx - the agent's scope context.
* @returns the preset id, or undefined when the agent joined none.
*/
composedPreset(agentCtx: Context): string | undefined {
return standingMountFor(agentCtx)?.presetId
}
/** Whether this deployment configures a root locally authored presets go to. */
get authorable(): boolean {
return this.config.roots.some(root => root.trust === 'user')
+28 -8
View File
@@ -202,6 +202,33 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] {
return leaked.sort((left, right) => left.localeCompare(right))
}
/** A live standing mount located through one agent already joined to it. */
export type JoinedPresetMount = PresetMount & {
/** The standing key, definite because it is what the lookup matched on. */
readonly key: ScopeKey
}
/**
* The standing composition one agent is joined to.
*
* The agent's own key is parented to its preset's standing key, so the mount
* is found by matching that parent rather than by walking up from the agent —
* the mount is not under the agent's fiber. An agent that joined no preset —
* a deployment composing no roster, or a child agent before its join — has no
* parent link and resolves to undefined.
* @param agentCtx - the agent's scope context.
* @returns the mount the agent joined, or undefined when it joined none.
*/
export function standingMountFor(agentCtx: Context): JoinedPresetMount | undefined {
const agentKey = scopeOf(agentCtx)
if (agentKey === undefined) return undefined
const standingKey = scopeParentOf(agentKey)
if (standingKey === undefined) return undefined
return livePresetMounts().find(
(candidate): candidate is JoinedPresetMount => candidate.key === standingKey,
)
}
/**
* One agent's instance of a service its preset mounted.
*
@@ -231,14 +258,7 @@ export function serviceForAgent<K extends string & keyof Context>(
agent: { ctx: Context },
name: K,
): Context[K] | undefined {
// The agent's own key is parented to its preset's standing key; the mount
// is no longer under the agent's fiber, so the search roots at the standing
// mount instead of walking up from the agent.
const agentKey = scopeOf(agent.ctx)
if (agentKey === undefined) return undefined
const standingKey = scopeParentOf(agentKey)
if (standingKey === undefined) return undefined
const mount = livePresetMounts().find(candidate => candidate.key === standingKey)
const mount = standingMountFor(agent.ctx)
if (mount === undefined) return undefined
const store = ctx.reflect.store
for (const key of Object.getOwnPropertySymbols(store)) {
@@ -153,6 +153,79 @@ describe('composing an agent from a preset', () => {
})
})
describe('composing a child agent from its parent', () => {
/** Create one agent joined to `parent`'s composition, as a child creation window does. */
async function childOf(ctx: Context, id: string, parent: Agent): Promise<Agent> {
const handle = await ctx.agents.create({
sessionId: SessionId(id),
setup: (childCtx: Context) => void ctx.agentPresets.composeFrom(childCtx, parent.ctx),
})
return handle.agent
}
it('gives the child its parent\'s tools and prompt sections', async () => {
const parent = await agentOn(ctx, 'sess-parent', 'standard')
const child = await childOf(ctx, 'sess-child', parent)
expect(toolNames(ctx, child)).toEqual(['alpha'])
const prompt = await ctx.systemPrompt.assemble(assembleContextFor(child))
expect(prompt.sections.map(section => section.name)).toContain('preset:alpha')
})
it('joins the parent\'s own generation rather than remounting its preset', async () => {
const parent = await agentOn(ctx, 'sess-shared', 'standard')
const before = livePresetMounts().length
await childOf(ctx, 'sess-shared-child', parent)
// A remount would compose a second copy of every row in the preset; the
// child must run on the plugin instances its parent already runs on.
expect(livePresetMounts()).toHaveLength(before)
})
it('keeps the child composed after its parent is disposed', async () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('sess-dying-parent'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
const child = await childOf(ctx, 'sess-orphan', parentHandle.agent)
await parentHandle.dispose()
// Standing mounts outlive the agents that joined them, so a child outliving
// its parent — a background subagent — keeps the composition it started on.
expect(toolNames(ctx, child)).toEqual(['alpha'])
})
it('reports the preset id the child joined, for the durable header', async () => {
const parent = await agentOn(ctx, 'sess-named', 'minimal')
const child = await childOf(ctx, 'sess-named-child', parent)
expect(ctx.agentPresets.composedPreset(parent.ctx)).toBe('minimal')
expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('minimal')
})
it('composes nothing when the parent joined no preset', async () => {
// The rosterless deployment: model-facing rows sit in the host composition
// and the child already resolves them through the registry's global layer.
const bare = (await ctx.agents.create({ sessionId: SessionId('sess-bare-parent') })).agent
const child = await childOf(ctx, 'sess-bare-child', bare)
expect(ctx.agentPresets.composedPreset(bare.ctx)).toBeUndefined()
expect(ctx.agentPresets.composeFrom(child.ctx, bare.ctx)).toBeUndefined()
expect(toolNames(ctx, child)).toEqual([])
})
it('refuses to compose an unscoped context', async () => {
const parent = await agentOn(ctx, 'sess-unscoped-parent', 'standard')
expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow(/unscoped context/)
})
})
describe('rejecting a composition that cannot be used', () => {
it('refuses to mount into a context that carries no agent scope', async () => {
await expect(ctx.agentPresets.mount(ctx, 'standard'))