Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup
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/core/tools/README.md
|
||||
README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e
|
||||
README.zh.md: d7766b432c5a319d214da80e3df438489519be92
|
||||
README.md: 21851ca887147364c76612bae2e6a00ebdccec39
|
||||
README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f
|
||||
|
||||
@@ -19,7 +19,7 @@ tools:
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
|
||||
@@ -19,7 +19,7 @@ tools:
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
|
||||
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
|
||||
|
||||
@@ -647,13 +647,14 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
||||
* scoped registrations or the reserved Code Mode transport.
|
||||
* 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 or the reserved Code Mode transport.
|
||||
*/
|
||||
export interface ToolRestriction {
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
/** Inherited tool names that stay visible; every other inherited one is removed. */
|
||||
readonly allow?: readonly string[]
|
||||
/** Global tool names removed from visibility. */
|
||||
/** Inherited tool names removed from visibility. */
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
|
||||
@@ -669,7 +670,7 @@ interface ToolView {
|
||||
readonly visible: ReadonlyMap<string, ToolDefinition>
|
||||
/** Pre-restriction capability names used by prompt-order validation. */
|
||||
readonly knownNames: ReadonlySet<string>
|
||||
/** Current global names that a scoped restriction may name. */
|
||||
/** Current inherited names a scoped restriction may name; its own are exempt. */
|
||||
readonly restrictableNames: ReadonlySet<string>
|
||||
}
|
||||
|
||||
@@ -707,7 +708,7 @@ class ToolLayer implements ScopeLayer {
|
||||
&& this.mode === undefined
|
||||
}
|
||||
|
||||
/** Whether every compiled restriction in this layer admits a global tool name. */
|
||||
/** Whether every compiled restriction in this layer admits an inherited tool name. */
|
||||
admits(name: string): boolean {
|
||||
for (const filter of this.restrictions.values()) {
|
||||
if ((filter.allow !== undefined && !filter.allow.has(name))
|
||||
@@ -1029,7 +1030,7 @@ export class ToolRegistry extends Service {
|
||||
const known = this.view(scope).restrictableNames
|
||||
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
@@ -1070,30 +1071,54 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Resolve every registry fact one scope needs in one layer traversal. The
|
||||
* visible map applies global restrictions, scoped shadowing, and the reserved
|
||||
* presentation transport; the other sets retain the pre-restriction facts
|
||||
* needed by restriction and prompt-order validation.
|
||||
* visible map applies restrictions to the INHERITED surface, then the
|
||||
* scope's own registrations and the reserved presentation transport; the
|
||||
* other sets retain the pre-restriction facts needed by restriction and
|
||||
* prompt-order validation.
|
||||
*
|
||||
* A restriction filters what a scope inherits — the global layer and every
|
||||
* ancestor layer on its chain — and never what its OWN layer registers.
|
||||
* That exemption is what a per-child capability filter has to keep intact:
|
||||
* the delegation runtime registers a child's reporting and structured-output
|
||||
* tools into the child's own layer, and a filter naming the capabilities the
|
||||
* child may use must not strip the machinery it answers through.
|
||||
*
|
||||
* Reading the exempt set as "the global layer" instead of "not mine" held
|
||||
* only while every model-facing tool sat in the host composition. Once
|
||||
* presets moved them onto the agent plane they became an ANCESTOR
|
||||
* contribution, so a child's filter silently stopped constraining anything
|
||||
* it was given.
|
||||
* @param scope - the viewing scope (the agent), or undefined for the global view.
|
||||
* @returns the complete derived view for that scope.
|
||||
*/
|
||||
private view(scope?: ScopeKey): ToolView {
|
||||
// Scope-chain layers, farthest ancestor first, the exact scope last.
|
||||
const layers = this.layers.chainLayers(scope)
|
||||
// Chain-blind on purpose: this is the ONE layer whose registrations the
|
||||
// scope owns rather than inherits, and it is absent until the scope
|
||||
// contributes something.
|
||||
const own = this.layers.peek(scope)
|
||||
// Inherited surface, nearest ancestor last: a nearer scope's same-name
|
||||
// entry shadows a farther one, and the global layer is the farthest.
|
||||
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
|
||||
for (const layer of layers) {
|
||||
if (layer === own) continue
|
||||
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
|
||||
}
|
||||
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()) {
|
||||
for (const [name, definition] of inherited) {
|
||||
knownNames.add(name)
|
||||
restrictableNames.add(name)
|
||||
// Restrictions intersect across the whole chain: any scope on it may
|
||||
// mask a global-surface name for everything nested inside it.
|
||||
// mask an inherited name for everything nested inside it.
|
||||
if (layers.every(layer => layer.admits(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()) {
|
||||
// The scope's own registrations last, shadowing an inherited name and
|
||||
// outside the filter above.
|
||||
if (own !== undefined) {
|
||||
for (const [name, definition] of own.tools.entries()) {
|
||||
knownNames.add(name)
|
||||
visible.set(name, definition)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -181,21 +181,84 @@ describe('restrict()', () => {
|
||||
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
|
||||
it('fails loud on an unscoped call, an empty filter, and names it does not inherit', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('real'))
|
||||
scope.ctx.tools.register(tool('local'))
|
||||
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
|
||||
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
|
||||
// A scope's own registration is exempt from its own filter, so naming it
|
||||
// is a caller error rather than a silent no-op.
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/)
|
||||
|
||||
const emptyCtx = await mount()
|
||||
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
|
||||
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
|
||||
.toThrow(/known global tools: \(none\)/)
|
||||
.toThrow(/Restrictable tools: \(none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restrict() over an inherited scope layer', () => {
|
||||
/** Mint a child scope parented to `parent`, as a subagent's creation window does. */
|
||||
async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> {
|
||||
const key = { id: name as SessionId } as Agent
|
||||
bindScopeParent(key, parentKey)
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
|
||||
{ inject: ['tools', 'systemPrompt'] }))
|
||||
return { scope, key }
|
||||
}
|
||||
|
||||
it('filters tools the child inherits from an ancestor scope, not only global ones', async () => {
|
||||
// The shape every preset deployment has: no model-facing row in the global
|
||||
// layer, all of them contributed by an ancestor scope the child joined.
|
||||
const ctx = await mount()
|
||||
const parent = await mintAgentScope(ctx, 'parent')
|
||||
parent.scope.ctx.tools.register(tool('bash'))
|
||||
parent.scope.ctx.tools.register(tool('read'))
|
||||
const child = await mintChild(ctx, parent.key, 'child')
|
||||
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
|
||||
child.scope.ctx.tools.restrict({ deny: ['bash'] })
|
||||
|
||||
// Reading the exempt set as "the global layer" left this unfiltered, and
|
||||
// the name unrestrictable in the first place.
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read'])
|
||||
expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"')
|
||||
// The ancestor keeps its whole surface: a child's filter is its own.
|
||||
expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read'])
|
||||
})
|
||||
|
||||
it('keeps the child\'s own registrations outside its own filter', async () => {
|
||||
// The delegation runtime registers a child's reporting and structured
|
||||
// output tools into the child's own layer; an `allow` naming only the
|
||||
// capabilities the child may use must not strip them.
|
||||
const ctx = await mount()
|
||||
const parent = await mintAgentScope(ctx, 'parent')
|
||||
parent.scope.ctx.tools.register(tool('bash'))
|
||||
parent.scope.ctx.tools.register(tool('read'))
|
||||
const child = await mintChild(ctx, parent.key, 'child')
|
||||
child.scope.ctx.tools.register(tool('report'))
|
||||
|
||||
child.scope.ctx.tools.restrict({ allow: ['read'] })
|
||||
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report'])
|
||||
expect(await run(ctx, 'report', child.key)).toBe('ran:report')
|
||||
})
|
||||
|
||||
it('lets an ancestor\'s restriction reach every scope nested inside it', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('web'))
|
||||
const parent = await mintAgentScope(ctx, 'parent')
|
||||
parent.scope.ctx.tools.register(tool('bash'))
|
||||
const child = await mintChild(ctx, parent.key, 'child')
|
||||
parent.scope.ctx.tools.restrict({ deny: ['web'] })
|
||||
|
||||
expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash'])
|
||||
expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PresetNotWritableError, resolveSessionPreset,
|
||||
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
@@ -1037,7 +1038,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* common paths — reconnecting, resuming, retrying a create — are unaffected.
|
||||
* @param sessionId - the identity being adopted.
|
||||
* @param requested - the preset the request named, if any.
|
||||
* @param existing - the preset the session was created under, if any.
|
||||
* @param existing - the preset the session RUNS, if any; both callers resolve
|
||||
* it from the log, which differs from the creation header once a blank
|
||||
* session has switched.
|
||||
* @throws when both are present and differ.
|
||||
*/
|
||||
function assertPresetUnchanged(
|
||||
@@ -1350,17 +1353,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* The registry view scope a transcript's presenters resolve in.
|
||||
*
|
||||
* A live agent is that scope itself (its chain passes through its preset's
|
||||
* standing layer). A cold session names its preset on the header, and the
|
||||
* standing layer). A cold session resolves its preset from the LOG, and the
|
||||
* preset's STANDING key serves without resuming anything — ensuring the
|
||||
* mount composes plugins but starts no agent, session, or turn. No roster,
|
||||
* no recorded preset, or a preset the roster no longer supplies all fall
|
||||
* back to the global layer: the transcript still serves, with the generic
|
||||
* cards a viewless entry renders.
|
||||
*
|
||||
* Reading the header alone would render a session that switched while blank
|
||||
* through the composition it was CREATED with. Every tool only the newer
|
||||
* preset registers resolves to no presenter there, and the transcript
|
||||
* silently degrades to generic cards for exactly the calls its history is
|
||||
* made of.
|
||||
* @param sessionId - the transcript being read.
|
||||
* @param header - that session's header (attached or inspected).
|
||||
* @param session - that session's header and log (attached or inspected).
|
||||
* @returns the scope to pass to presenter lookups, or undefined for global.
|
||||
*/
|
||||
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
|
||||
async function presenterScopeFor(
|
||||
sessionId: SessionId,
|
||||
session: PresetBearingSession,
|
||||
): Promise<ScopeKey | undefined> {
|
||||
const live = ctx.get('agents')?.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const presets = ctx.get('agentPresets')
|
||||
@@ -1370,7 +1382,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// through the DEFAULT preset's standing layer: that is the composition
|
||||
// an unnamed session composes today, and presenters are pure display,
|
||||
// so the worst a mismatch produces is the generic card it had anyway.
|
||||
return await presets.standingKeyFor(header.agentPreset)
|
||||
return await presets.standingKeyFor(resolveSessionPreset(session))
|
||||
} catch {
|
||||
// Swallows only the unknown/unusable-preset rejection from the roster:
|
||||
// a deleted or broken preset must degrade this read, never fail it.
|
||||
@@ -1463,7 +1475,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Beside the cwd check for the same reason, and after the await so it
|
||||
// covers every path that yields a live agent — freshly created, adopted
|
||||
// live, resumed from disk, or recovered by the concurrent-creation catch.
|
||||
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
|
||||
assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
@@ -1979,12 +1991,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
}
|
||||
// Echo the RESOLVED composition so a client can label the session it
|
||||
// just created without waiting for the next list refresh — the create
|
||||
// is the commit point that knows it (a caller that named none gets
|
||||
// the default the header recorded).
|
||||
// Echo the composition the session RUNS so a client can label it
|
||||
// without waiting for the next list refresh — the create is the commit
|
||||
// point that knows it (a caller that named none gets the default).
|
||||
// Resolved from the log for the same reason `sessionListFields()` is:
|
||||
// this handler also adopts an already-live session, and one that
|
||||
// switched while blank runs a preset its header no longer names, so
|
||||
// echoing the header would contradict both the adoption this call just
|
||||
// allowed and the row `session.list` serves for the same session.
|
||||
const created = ctx.agents.get(sessionId)
|
||||
const createdPreset = created?.session.header.agentPreset
|
||||
const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
|
||||
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
|
||||
},
|
||||
|
||||
@@ -2003,7 +2019,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
@@ -2982,7 +2998,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// The scope presenters resolve in — the live agent, else the recorded
|
||||
// preset's standing key, else the global layer — so a cold session's
|
||||
// '/' popup lists the catalog its composition actually serves.
|
||||
const scope = await presenterScopeFor(sessionId, session.header)
|
||||
const scope = await presenterScopeFor(sessionId, session)
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
||||
return ok(request, {
|
||||
|
||||
@@ -186,6 +186,29 @@ describe('session.create with an agent preset', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts a live session under the preset it SWITCHED to', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
|
||||
// Exactly what `agentPreset.select` leaves behind on a blank session: the
|
||||
// header keeps the creation fact, the log states what the agent runs.
|
||||
ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }))
|
||||
const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
|
||||
|
||||
// Comparing against the header would invert both answers: the preset the
|
||||
// session actually runs would be refused, and the one it left would pass.
|
||||
expect(adopted.result.ok).toBe(true)
|
||||
// The echo has to name the same preset the adoption just accepted, or the
|
||||
// client labels the session with one it has already left — and disagrees
|
||||
// with the row `session.list` serves for it.
|
||||
if (!adopted.result.ok) throw new Error('unreachable')
|
||||
expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' })
|
||||
expect(stale.result.ok).toBe(false)
|
||||
if (stale.result.ok) throw new Error('unreachable')
|
||||
expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' })
|
||||
})
|
||||
|
||||
it('adopts a live session unchanged when the caller names no preset', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
|
||||
@@ -660,6 +683,27 @@ describe('session.history presenter scope', () => {
|
||||
expect(standingKeyRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves a switched session from the LOG, not its creation header', async () => {
|
||||
// The header is a creation fact; a switch while blank is a logged event,
|
||||
// and every turn after it ran under the newer composition. Reading the
|
||||
// header would render that history through the older preset's layer,
|
||||
// where the tools it is made of have no presenter at all.
|
||||
const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' }
|
||||
const { api } = await harness(['standard', 'minimal'], {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({
|
||||
meta,
|
||||
events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }],
|
||||
}),
|
||||
})
|
||||
|
||||
standingKeyRequests.length = 0
|
||||
const response = await api.sessions.history(request({ sessionId: SessionId('p4') }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
expect(standingKeyRequests).toEqual(['minimal'])
|
||||
})
|
||||
|
||||
it('serves a COLD transcript whose standing mount is no longer usable', async () => {
|
||||
// A genuinely cold session: persistence knows it, no live agent exists.
|
||||
const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -110,6 +110,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async mount(agentCtx: Context, id?: string): Promise<AgentPreset>',
|
||||
jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined',
|
||||
jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous, and with no composition failure mode of its own — it reads no\n * roster, mounts nothing, and touches no file — which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`. It still rejects a caller error, as\n * the `@throws` below record.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'composedPreset(agentCtx: Context): string | undefined',
|
||||
jsDoc: '/**\n * The preset one live agent runs on.\n *\n * Read from the live scope chain rather than from the session, so it answers\n * for an agent whose session has not recorded a preset yet — a child agent\n * whose durable header is being built from its parent\'s composition.\n * @param agentCtx - the agent\'s scope context.\n * @returns the preset id, or undefined when the agent joined none.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async read(id: string): Promise<string>',
|
||||
jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */',
|
||||
|
||||
@@ -45,9 +45,12 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function startInProcessRun(
|
||||
if (inheritedPolicy !== undefined) {
|
||||
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
|
||||
}
|
||||
applyChildComposition(childCtx, {
|
||||
applyChildComposition(childCtx, parent, {
|
||||
persona: request.persona,
|
||||
toolFilter: request.toolFilter,
|
||||
})
|
||||
|
||||
20
packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js
vendored
Normal file
20
packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
// A preset row standing in for the agent-plane tool rows a real preset mounts.
|
||||
// Import-free on purpose — the Loader resolves entry modules through Node's ESM
|
||||
// resolver, which cannot see this workspace's TypeScript sources.
|
||||
export const name = 'preset-tool'
|
||||
export const inject = ['tools', 'systemPrompt']
|
||||
|
||||
export function apply(ctx, config) {
|
||||
ctx.effect(() => ctx.tools.register({
|
||||
name: config.tool,
|
||||
description: `fixture tool ${config.tool}`,
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
|
||||
execute: () => Promise.resolve(config.tool),
|
||||
}))
|
||||
ctx.effect(() => ctx.systemPrompt.section({
|
||||
name: `preset:${config.tool}`,
|
||||
order: 10,
|
||||
text: `section for ${config.tool}`,
|
||||
}))
|
||||
}
|
||||
5
packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml
vendored
Normal file
5
packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Agent-plane composition: the model-facing row lives here, not in the host.
|
||||
- id: only
|
||||
name: ../../plugins/preset-tool.js
|
||||
config:
|
||||
tool: preset_only
|
||||
6
packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml
vendored
Normal file
6
packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# A second agent-plane composition, so a switch is a real switch: the tool a
|
||||
# joined child sees has to change with it.
|
||||
- id: only
|
||||
name: ../../plugins/preset-tool.js
|
||||
config:
|
||||
tool: reviewing_only
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Composition inheritance: a child runs on the preset its parent runs on.
|
||||
*
|
||||
* With every model-facing row on the agent plane, the tool registry's global
|
||||
* layer is empty, so a child that joins no preset reaches the model with no
|
||||
* tools at all. These assert the model-visible result — the schemas in the
|
||||
* child's own request — rather than the join that produces it.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import AgentPresets from '@deepseek-ai/dsh-agent-presets'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }]
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
/** A host composition carrying no model-facing rows, plus the preset roster. */
|
||||
async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS })
|
||||
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('parent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'),
|
||||
})
|
||||
return { ctx, adapter, parent: handle.agent }
|
||||
}
|
||||
|
||||
/** The one-shot spawn request shape both in-process providers build. */
|
||||
function spawnRequest(parent: Agent) {
|
||||
return {
|
||||
label: 'child task',
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
descriptor: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot' as const,
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('a child agent composed in-process', () => {
|
||||
it('reaches the model with its parent\'s preset tools', async () => {
|
||||
const { ctx, adapter, parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
const childRequest = adapter.requests.at(-1)
|
||||
expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only'])
|
||||
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only'])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('carries its parent\'s prompt sections', async () => {
|
||||
const { parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
expect(run.localAgent?.session.events.some(event =>
|
||||
event.type === 'request/header'
|
||||
&& JSON.stringify(event.data).includes('section for preset_only'))).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('records the composition it ran under on the child header', async () => {
|
||||
const { parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
// Without this the child's own history reads back under the deployment
|
||||
// default, which is a different tool set than the one it actually used.
|
||||
expect(run.localAgent?.session.header.agentPreset).toBe('coding')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('honours a tool filter over the preset tools it inherited', async () => {
|
||||
const { ctx, parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(
|
||||
{ ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } },
|
||||
{},
|
||||
)
|
||||
await run.result
|
||||
|
||||
// The capability filter is the only thing bounding a delegated child, and
|
||||
// every tool it can name now arrives from the preset rather than the host.
|
||||
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('follows a parent that switched preset while blank', async () => {
|
||||
const { ctx, parent } = await setupPresetHost()
|
||||
// A DIFFERENT preset, so the assertion below distinguishes reading the
|
||||
// parent's live scope chain from reading its creation header — re-linking
|
||||
// to the same id would pass either way.
|
||||
await ctx.agentPresets.recompose(parent.ctx, 'reviewing')
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only'])
|
||||
expect(run.localAgent?.session.header.agentPreset).toBe('reviewing')
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
@@ -298,7 +298,7 @@ describe('startInProcessRun', () => {
|
||||
await expect(startInProcessRun({
|
||||
...request(parent),
|
||||
toolFilter: { deny: ['unknown-tool'] },
|
||||
}, {})).rejects.toThrow('unknown global tool')
|
||||
}, {})).rejects.toThrow('unknown inherited tool')
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
@@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
|
||||
})).rejects.toThrow(/unknown inherited tool "no_such_tool"/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/subagent/subagent/README.md
|
||||
README.md: 762030629c09305c48adebc71244655a5faa6585
|
||||
README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff
|
||||
README.md: b69428e4af7d1f53adb22be1e59beb79c054713f
|
||||
README.zh.md: 9f5eb5f1c508135c21bf3923f60f4f872de8e9f6
|
||||
|
||||
@@ -40,6 +40,10 @@ Start-time features are advertised in `provider.capabilities` because the servic
|
||||
- `toolFilter` — apply the requested child tool restriction.
|
||||
- `persona` — apply a per-child persona.
|
||||
|
||||
Every in-process child is composed by one call, `applyChildComposition(childCtx, parent, composition)`, which joins the parent's agent-preset composition before applying the child's own persona and tool filter. The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry ([`dsh-agent-presets`](../../preset/agent-presets/README.md)). Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent. A deployment composing no preset roster joins nothing and needs nothing: its model-facing rows sit in the host composition, where the child already resolves them through the tool registry's global layer.
|
||||
|
||||
`childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one.
|
||||
|
||||
Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation.
|
||||
|
||||
## The durable descriptor
|
||||
|
||||
@@ -40,6 +40,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
- `toolFilter`:应用请求的子 agent 工具限制;
|
||||
- `persona`:应用每个子 agent 独立的 persona。
|
||||
|
||||
每个进程内子 agent 都由一次调用完成组装:`applyChildComposition(childCtx, parent, composition)` 先加入父方的 agent-preset 组装,再应用该子 agent 自己的 persona 与工具限制。加入组装正是子 agent 获得能力的途径:所有面向模型的行都在 agent 平面,没有加入任何组装的子 agent 抵达模型时工具注册表是空的(见 [`dsh-agent-presets`](../../preset/agent-presets/README.md))。把父方作为参数是刻意的——这让"组装一个子 agent 却不做该加入"在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组装、也不需要加入:它的面向模型的行位于宿主组装中,子 agent 已经能通过工具注册表的全局层解析到它们。
|
||||
|
||||
`childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。
|
||||
|
||||
可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。
|
||||
|
||||
## 持久化描述符
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-presets": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
@@ -47,6 +48,9 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-agent-presets": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -62,6 +66,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,12 @@ import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
// Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when
|
||||
// composed — a child inherits its parent's composition opportunistically (the
|
||||
// documented `ctx.get` pattern), never as a hard dep. A rosterless deployment
|
||||
// keeps its model-facing rows on the host plane, where the child already sees
|
||||
// them through the tool registry's global layer.
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { delegationDepthOf } from './depth.ts'
|
||||
|
||||
/** Thrown when starting a child would exceed the requested depth cap. */
|
||||
@@ -72,8 +78,15 @@ export function resolveChildAgentOptions(
|
||||
/**
|
||||
* Build the child session's durable creation metadata: the parent's workspace,
|
||||
* its direct lineage, coarse product origin, the recursion budget that must
|
||||
* survive persistence, and the seed boundary that separates inherited parent
|
||||
* history from child work.
|
||||
* survive persistence, the seed boundary that separates inherited parent
|
||||
* history from child work, and the composition the child runs under.
|
||||
*
|
||||
* The preset is read from the parent's LIVE scope chain rather than from its
|
||||
* header, because a parent that switched preset while blank runs on the newer
|
||||
* composition and its header still names the older one. Recording it is what
|
||||
* makes a child's history reconstructable: without it a cold read of the child
|
||||
* resolves the deployment default and rebuilds turns under a tool set the
|
||||
* child never had.
|
||||
* @param parent - the delegating parent agent.
|
||||
* @param childDepth - the resolved delegation depth to persist.
|
||||
* @param lineageSeedLength - how many leading events came from the parent's log.
|
||||
@@ -85,8 +98,10 @@ export function childSessionMeta(
|
||||
lineageSeedLength: number,
|
||||
): NonNullable<CreateAgentOptions['meta']> {
|
||||
const parentHeader = parent.session.header
|
||||
const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx)
|
||||
return {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
...agentPreset === undefined ? {} : { agentPreset },
|
||||
parentSession: parentHeader.id,
|
||||
// Navigation classification only; the descriptor remains the authority
|
||||
// for mode and continuation capability.
|
||||
@@ -106,13 +121,31 @@ export interface ChildComposition {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one child's scoped composition inside its creation window: a shadowing
|
||||
* persona section and a tool restriction, both owned by the child's scope and
|
||||
* therefore invisible to its parent and siblings.
|
||||
* Compose one child inside its creation window: join its parent's preset, then
|
||||
* apply the child's own shadowing persona section and tool restriction, both
|
||||
* owned by the child's scope and therefore invisible to its parent and
|
||||
* siblings.
|
||||
*
|
||||
* The join comes first and the child's own registrations second, which is the
|
||||
* order the layering already implies — the nearest scope wins a name, and a
|
||||
* per-child restriction intersects with everything its chain admits — but
|
||||
* stating it here keeps the two steps from being read as independent.
|
||||
*
|
||||
* Both steps live in ONE call because a child composed with only the second is
|
||||
* exactly the defect this function exists to prevent: with every model-facing
|
||||
* row on the agent plane, a child that joins no preset sees an empty tool
|
||||
* registry and none of its parent's prompt sections. Taking the parent as a
|
||||
* parameter is what makes that omission unrepresentable at the call sites.
|
||||
* @param childCtx - the child agent's scoped creation context.
|
||||
* @param composition - the persona and tool filter to install.
|
||||
* @param parent - the delegating parent whose composition the child joins.
|
||||
* @param composition - the per-child persona and tool filter to install.
|
||||
*/
|
||||
export function applyChildComposition(childCtx: Context, composition: ChildComposition): void {
|
||||
export function applyChildComposition(
|
||||
childCtx: Context,
|
||||
parent: Agent,
|
||||
composition: ChildComposition,
|
||||
): void {
|
||||
childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx)
|
||||
if (composition.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona })
|
||||
}
|
||||
|
||||
@@ -885,7 +885,7 @@ export class SubagentContinuationManager {
|
||||
// some other owner holds — a duplicate would reject there with rollback.
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): AgentSetupCommit => {
|
||||
applyChildComposition(childCtx, inputs.composition)
|
||||
applyChildComposition(childCtx, parent, inputs.composition)
|
||||
return this.setupRegistry.apply(childCtx)
|
||||
}
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user