Merge remote-tracking branch 'origin/master' into fix/windows-native-acl-coverage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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——都走解析,而非直接读头部。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user