fix(apiproxy): serve history events and projections from one log position
Review found two defects in the previous commit's ordering fix. @pku-xht: `historyStateFor` copied the attached session's events, the handler then awaited `presenterScopeFor`, and only then read the projection baseline off the still-live Session. An append during that await served events cut at N beside a baseline folded to N+1 — one response describing two moments. The same restructure had also moved the baseline read outside the `try`, so a failing snapshot escaped the structured `internal` error. Both awaits now happen before the cut: `historySourceFor` resolves which session serves the read, `presenterScopeFor` ensures the recorded composition, and `historyCutOf` then reads events and baseline adjacently with nothing between them. The whole sequence is back inside the try. The invariant judged any scoped assembly with a chain of one as an unjoined agent, which rejects a legitimate assembly in a standing preset key (that key has no parent of its own). It now gates on `context.agent` — a scope-only read is not an agent and is out of range by construction rather than by a premise about who else calls `assemble` — and asks the roster's own `composedPreset()` instead of introspecting chain length. The advisory warning uses the same relation. Also from review: the `2026-08-05-per-agent-tool-presentation` note still described `presentAs` as per-agent, which standing mounts made false and this branch's own rewording contradicts; the duplicated "process-wide unit table" argument collapses to the Agent Note with pointers from the five copies; a dead `.sort()` before `arrayContaining`; and change-history narration in the tool-cordis README.
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 .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md
|
||||
2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb
|
||||
2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553
|
||||
2026-08-05-per-agent-tool-presentation.md: adb93b51c73d341c153b8fcafe2a08f0a5598478
|
||||
2026-08-05-per-agent-tool-presentation.zh.md: fa83bd4daec9d8e6e9e43295bb81af97782b1fdf
|
||||
@@ -12,16 +12,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to
|
||||
|
||||
## Decision
|
||||
|
||||
Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there.
|
||||
Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there.
|
||||
|
||||
`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's.
|
||||
`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-mode` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's.
|
||||
|
||||
Two consequences fell out and are load-bearing:
|
||||
|
||||
- **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need.
|
||||
- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted.
|
||||
|
||||
The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section.
|
||||
The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section.
|
||||
|
||||
The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act.
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些
|
||||
|
||||
## Decision
|
||||
|
||||
把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。
|
||||
把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。
|
||||
|
||||
`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。
|
||||
`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-mode` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。
|
||||
|
||||
有两个随之而来的结果,且都是承重的:
|
||||
|
||||
- **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。
|
||||
- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。
|
||||
|
||||
SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。
|
||||
SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。
|
||||
|
||||
preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。
|
||||
|
||||
|
||||
@@ -159,7 +159,9 @@ describe('the shipped Web composition', () => {
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(Object.keys(projections.snapshot(handle.agent.session).values).sort())
|
||||
// A subset assertion: `tasks`, `goal`, and the rest register into the
|
||||
// same process-wide table, and this is about the meter's three units.
|
||||
expect(Object.keys(projections.snapshot(handle.agent.session).values))
|
||||
.toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage']))
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
|
||||
@@ -253,11 +253,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
validateSessionParams(params)
|
||||
const sessionId = SessionId(randomUUID())
|
||||
// No preset composition: the ACP bundle keeps the model-facing rows in
|
||||
// the host plane, so this agent reads them from the global registry
|
||||
// layer. A deployment that configures `dsh-agent-presets` has to join
|
||||
// one here first — registration visibility inherits only along the
|
||||
// `dsh-scope` parent chain, and an agent created without that link
|
||||
// reads an empty global layer and reaches the model with no tools.
|
||||
// the host plane, so this agent reads them from the global layer. A
|
||||
// deployment that configures a roster has to join one here first
|
||||
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
|
||||
@@ -107,12 +107,9 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
|
||||
const selection = defaultModel.currentSelection()
|
||||
// This bundle composes no preset roster, so the model-facing rows sit in the
|
||||
// host plane and the agent reads them from the global registry layer. A
|
||||
// deployment that DOES configure `dsh-agent-presets` has to join one here
|
||||
// first: registration visibility inherits only along the `dsh-scope` parent
|
||||
// chain, and `AgentPresets.mount()` is the only thing that links an agent to
|
||||
// it, so an agent created without that link reads an empty global layer and
|
||||
// reaches the model with no tools at all.
|
||||
// host plane and the agent reads them from the global layer. A deployment
|
||||
// that DOES configure one has to join it here first
|
||||
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
||||
const { agent } = await agents.create({
|
||||
sessionId: SessionId(`session-${randomUUID()}`),
|
||||
meta: { cwd: process.cwd() },
|
||||
|
||||
@@ -301,16 +301,11 @@
|
||||
disabled: true
|
||||
|
||||
# The token METER stays on the host plane; only the compaction backend that
|
||||
# reads it moves. It takes no configuration at all, keys every fold by Session,
|
||||
# and registers no tool or prompt section, so one host instance serves every
|
||||
# preset exactly as before. It also owns the `tokenUsage`/`contextPressure`/
|
||||
# `contextBreakdown` projection units, and the projection registry is
|
||||
# process-wide rather than scope-layered: registered from inside a preset, the
|
||||
# browser's context meter would appear for a session whose own composition
|
||||
# mounts no meter as soon as some OTHER session mounted one, and disappear
|
||||
# entirely in a process that only ever ran `minimal`. That is the same criterion
|
||||
# `tasks` and `goals` are read by — a Service the host and every preset can see
|
||||
# belongs to the plane both can see.
|
||||
# reads it moves. It owns the context-meter projection units, and that table is
|
||||
# process-wide, so preset ownership would make the meter a function of which
|
||||
# presets happen to be mounted rather than a per-session fact. Same criterion as
|
||||
# `tasks` and `goals`; the reasoning has one home in
|
||||
# `.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md`.
|
||||
|
||||
- id: compact-basic
|
||||
disabled: true
|
||||
|
||||
@@ -686,6 +686,16 @@ function historyPage(
|
||||
* registry). An absent registry means the deployment has no projection seam:
|
||||
* the whole block is absent and clients treat every key as capability-absent.
|
||||
*/
|
||||
/**
|
||||
* Which session a transcript read is served from. An attached session is the
|
||||
* live object and keeps appending, so its events and projection baseline are
|
||||
* read together in one synchronous step; a detached one is already a frozen
|
||||
* inspection.
|
||||
*/
|
||||
type HistorySource =
|
||||
| { readonly kind: 'attached'; readonly session: Session }
|
||||
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] }
|
||||
|
||||
function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
|
||||
const registry = ctx.get('sessionProjections')
|
||||
if (registry === undefined) return undefined
|
||||
@@ -1330,39 +1340,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one transcript cut without acquiring an Agent owner, plus a DEFERRED
|
||||
* read of the projection baseline.
|
||||
*
|
||||
* The baseline is a thunk rather than a value because the unit table is
|
||||
* process-wide while the units themselves are registered by preset rows: a
|
||||
* key like `todos` exists only once its preset's standing mount is composed.
|
||||
* The caller ensures that mount through {@link presenterScopeFor} — which
|
||||
* needs the header this function returns — so reading the snapshot eagerly
|
||||
* would serve a first cold read a page missing every preset-owned key, and
|
||||
* every later read a complete one.
|
||||
* Resolve which session one transcript read is served from, without
|
||||
* acquiring an Agent owner. This is the read's only asynchronous step
|
||||
* besides ensuring the composition; {@link historyCutOf} takes the cut.
|
||||
* @param sessionId - the transcript being read.
|
||||
* @returns the attached session, or the inspected detached header and events.
|
||||
* @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity.
|
||||
*/
|
||||
async function historyStateFor(
|
||||
sessionId: SessionId,
|
||||
includeProjections: boolean,
|
||||
): Promise<{
|
||||
header: SessionHeader
|
||||
events: SessionEvent[]
|
||||
readProjections: () => SessionProjectionsBlock | undefined
|
||||
}> {
|
||||
async function historySourceFor(sessionId: SessionId): Promise<HistorySource> {
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
return {
|
||||
header: attached.header,
|
||||
events: [...attached.events],
|
||||
readProjections: () => includeProjections ? projectionsFor(ctx, attached) : undefined,
|
||||
}
|
||||
}
|
||||
if (attached !== undefined) return { kind: 'attached', session: attached }
|
||||
const inspected = await inspectServable(sessionId)
|
||||
return {
|
||||
header: inspected.meta,
|
||||
events: inspected.events,
|
||||
readProjections: () => includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined,
|
||||
return { kind: 'detached', header: inspected.meta, events: inspected.events }
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and events {@link presenterScopeFor} reads to decide which
|
||||
* composition a transcript ran under.
|
||||
* @param source - the live or detached session this read is served from.
|
||||
* @returns that session's creation header and its events.
|
||||
*/
|
||||
function sourceSession(source: HistorySource): PresetBearingSession {
|
||||
if (source.kind === 'detached') return { header: source.header, events: source.events }
|
||||
return { header: source.session.header, events: source.session.events }
|
||||
}
|
||||
|
||||
/**
|
||||
* One transcript cut: the events and the projection baseline that describe
|
||||
* the SAME log position.
|
||||
*
|
||||
* Synchronous, and the two reads sit next to each other, because an attached
|
||||
* session keeps appending: an `await` between them would serve events cut at
|
||||
* N beside a baseline folded to N+1, which is one response describing two
|
||||
* moments. The caller does its awaiting before this call.
|
||||
* @param source - the live or detached session this read is served from.
|
||||
* @param includeProjections - whether the caller asked for the baseline (a tail page does).
|
||||
* @returns the events and, when asked, the baseline for that same position.
|
||||
*/
|
||||
function historyCutOf(
|
||||
source: HistorySource,
|
||||
includeProjections: boolean,
|
||||
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
|
||||
if (source.kind === 'detached') {
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
|
||||
return { events: source.events, ...projections === undefined ? {} : { projections } }
|
||||
}
|
||||
const events = [...source.session.events]
|
||||
const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined
|
||||
return { events, ...projections === undefined ? {} : { projections } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2022,9 +2047,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
async history(request) {
|
||||
const { sessionId, beforeSeq, maxMessages } = request.payload
|
||||
let state: Awaited<ReturnType<typeof historyStateFor>>
|
||||
try {
|
||||
state = await historyStateFor(sessionId, beforeSeq === undefined)
|
||||
const source = await historySourceFor(sessionId)
|
||||
// Both awaits happen BEFORE the cut. Ensuring the recorded
|
||||
// composition's standing mount is what registers its projection
|
||||
// units, so a first cold read would otherwise serve a baseline
|
||||
// missing every preset-owned key; and an attached session keeps
|
||||
// appending, so awaiting between the two reads would pair events cut
|
||||
// at N with a baseline folded to N+1.
|
||||
const scope = await presenterScopeFor(sessionId, sourceSession(source))
|
||||
const cut = historyCutOf(source, beforeSeq === undefined)
|
||||
const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope)
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
...cut.projections === undefined ? {} : { projections: cut.projections },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionNotFound) {
|
||||
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
|
||||
@@ -2035,17 +2073,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
// The scope resolves first: ensuring the recorded composition's
|
||||
// standing mount is what registers its projection units, so the
|
||||
// baseline below has to be read after it, not beside it.
|
||||
const scope = await presenterScopeFor(sessionId, state)
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, scope)
|
||||
const projections = state.readProjections()
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
...projections === undefined ? {} : { projections },
|
||||
})
|
||||
},
|
||||
|
||||
async models(request) {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { bindScopeParent, createScope, scopeChainOf, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
|
||||
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
|
||||
// Type-only: resolves the `agent/created` lifecycle event this service watches.
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
@@ -133,24 +133,21 @@ export class AgentPresets extends Service {
|
||||
}, 'agentPresets.settings()')
|
||||
})
|
||||
|
||||
// An agent joins a preset by having its scope key parented to a standing
|
||||
// mount, and `mount`/`composeFrom` are the only things in the runtime that
|
||||
// install that link. An agent that never joined keeps a chain of length
|
||||
// one, so its `tools`, `system-prompt`, and `skill` views resolve against
|
||||
// the EMPTY global layer and the model simply has nothing — no error, no
|
||||
// empty catalog to notice, just an agent that cannot act.
|
||||
// Advisory, not fatal: a synchronous `agent/created` listener that throws
|
||||
// VETOES publication, and this service must not, because composing an agent
|
||||
// outside the roster is legal — `recompose` binds exactly such a bare agent
|
||||
// below, and the ACP, SDK-server, and headless entry points all create one.
|
||||
// The invariant companion is the check that fails loud, at assembly. Why an
|
||||
// unjoined agent matters at all has one home: the [Agent
|
||||
// Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md).
|
||||
//
|
||||
// Advisory rather than fatal, and deliberately not the same observation the
|
||||
// invariant companion makes. A synchronous `agent/created` listener that
|
||||
// throws VETOES publication, and this service must not: composing an agent
|
||||
// outside the roster is legal (`recompose` documents the bare agent it then
|
||||
// binds, and entry points that predate presets still create one), so
|
||||
// vetoing would turn a capability gap into an outage. The companion fails
|
||||
// loud instead, at the later point where the empty world reaches a model.
|
||||
// Known false positive: a session created bare and bound later by
|
||||
// `recompose` is warned about once, before its first bind. No shipped flow
|
||||
// does that today — the Web surface mounts in `setup` and children join
|
||||
// through `composeFrom` before publication.
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
if (this.config.roots.length === 0) return
|
||||
const key = scopeOf(agent.ctx)
|
||||
if (key !== undefined && scopeChainOf(key).length > 1) return
|
||||
if (this.composedPreset(agent.ctx) !== undefined) return
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}" was published without joining an agent preset; `
|
||||
+ 'its tools, prompt sections, and skill catalog resolve against the empty global layer '
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { scopeChainOf } from '@deepseek-ai/dsh-scope'
|
||||
// Type-only: resolves the `system-prompt/assemble` waterfall this companion joins.
|
||||
// Type-only: resolves the `system-prompt/assemble` waterfall this companion
|
||||
// joins, and the `agent` field `dsh-agent` merges into its context.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
// Imported through the package name, not `./mount.ts`: a module shared between
|
||||
// the two build entry points becomes a third chunk that the published `files`
|
||||
// list does not carry, which `verify-built-package-invariants` rejects.
|
||||
@@ -42,26 +43,28 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
}
|
||||
}, { global: true })
|
||||
|
||||
// The join is a scope-parent link, and `AgentPresets.mount()` is the only
|
||||
// thing in the runtime that installs one. An agent minted without it keeps a
|
||||
// chain of length one, so its `tools`, `system-prompt`, and `skill` views
|
||||
// fall back to the empty global layer and the model receives nothing.
|
||||
// An agent that joined no preset resolves `tools`, `system-prompt`, and
|
||||
// `skill` against the empty global layer, so the model receives nothing.
|
||||
// `composedPreset()` is the roster's own answer to "did this agent join",
|
||||
// read from the live scope chain — see the [Agent
|
||||
// Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md)
|
||||
// for why the warning beside it is advisory while this one fails.
|
||||
//
|
||||
// Checked at ASSEMBLY, not at publication: an unjoined agent is legal until
|
||||
// it addresses a model — `recompose` binds a bare agent as its first link,
|
||||
// and that agent is unjoined for its whole life up to the switch. Assembling
|
||||
// a prompt is the point where the empty world stops being a state and
|
||||
// becomes what the model sees, and it is the only caller that supplies an
|
||||
// agent scope, so a host assembly (no scope) and a standing mount are both
|
||||
// correctly out of range.
|
||||
// Two conditions, each load-bearing. `context.agent` is what makes this an
|
||||
// AGENT assembly: a scope-only assembly — a cold read resolving presenters
|
||||
// in a standing key, a diagnostic — is not an agent and must not be judged
|
||||
// on whether it joined anything. And assembly rather than publication is the
|
||||
// moment that matters, because an unjoined agent is legal until it addresses
|
||||
// a model: `recompose` binds a bare agent as its first link, and that agent
|
||||
// is unjoined for its whole life up to the switch.
|
||||
ctx.on('system-prompt/assemble', (_assembly, context, next) => {
|
||||
const presets = ctx.get('agentPresets')
|
||||
const scope = context.scope
|
||||
const agent = context.agent
|
||||
if (presets !== undefined && presets.config.roots.length > 0
|
||||
&& scope !== undefined && scopeChainOf(scope).length === 1) {
|
||||
&& agent !== undefined && presets.composedPreset(agent.ctx) === undefined) {
|
||||
fail(
|
||||
'an agent addressed a model without joining any agent preset while a roster is composed; '
|
||||
+ 'its tools, prompt sections, and skill catalog resolve against the empty global layer',
|
||||
`agent "${agent.id}" addressed a model without joining any agent preset while a roster is `
|
||||
+ 'composed; its tools, prompt sections, and skill catalog resolve against the empty global layer',
|
||||
)
|
||||
}
|
||||
return next()
|
||||
|
||||
@@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -87,25 +87,31 @@ describe('agent-presets invariants', () => {
|
||||
|
||||
it('rejects an agent that addresses a model without joining any preset', async () => {
|
||||
const ctx = await harness()
|
||||
// The delegation shape: an agent composed outside the roster keeps a
|
||||
// scope chain of length one, so every registry view it reads is the empty
|
||||
// global layer. Publication alone stays legal — `recompose` binds exactly
|
||||
// such an agent — so nothing fires until that empty world reaches a prompt.
|
||||
// The delegation shape: an agent composed outside the roster joined no
|
||||
// standing mount, so every registry view it reads is the empty global
|
||||
// layer. Publication alone stays legal — `recompose` binds exactly such an
|
||||
// agent — so nothing fires until that empty world reaches a prompt.
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') })
|
||||
|
||||
await expect(ctx.systemPrompt.assemble({ scope: handle.agent }))
|
||||
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
|
||||
.rejects.toThrow(/without joining any agent preset/)
|
||||
})
|
||||
|
||||
it('admits a joined agent and a host assembly that names no scope', async () => {
|
||||
it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('inv-joined'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
||||
})
|
||||
|
||||
await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined()
|
||||
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
|
||||
// A scopeless assembly belongs to no agent, so it cannot be an unjoined one.
|
||||
await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined()
|
||||
// Neither can a scope that is not an agent at all: a standing preset key
|
||||
// has no parent of its own, so a chain-length rule would reject the cold
|
||||
// read that resolves presenters in it. `context.agent` is what keeps this
|
||||
// check to agent assemblies.
|
||||
const standing = await ctx.agentPresets.standingKeyFor('standard')
|
||||
await expect(ctx.systemPrompt.assemble({ scope: standing })).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -216,12 +216,10 @@ export class HarnessSdkServer {
|
||||
}
|
||||
|
||||
private async createSession(sessionId: string): Promise<SessionRecord> {
|
||||
// No preset composition: the SDK server's compositions keep the
|
||||
// model-facing rows in the host plane, so this agent reads them from the
|
||||
// global registry layer. A deployment that configures `dsh-agent-presets`
|
||||
// has to join one here first — registration visibility inherits only along
|
||||
// the `dsh-scope` parent chain, and an agent created without that link
|
||||
// reads an empty global layer and reaches the model with no tools.
|
||||
// No preset composition: this server's compositions keep the model-facing
|
||||
// rows in the host plane, so this agent reads them from the global layer. A
|
||||
// deployment that configures a roster has to join one here first
|
||||
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
||||
const handle = await this.ctx.agents.create({
|
||||
sessionId: SessionId(sessionId),
|
||||
meta: { cwd: this.cwd },
|
||||
|
||||
@@ -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/self-modification/tool-cordis/README.md
|
||||
README.md: 946d96c6f0968be3ecb162c52436f10985836b77
|
||||
README.zh.md: 8f94783f96e3652e27bdddd9cc7f54081f8c04bf
|
||||
README.md: be629ca9be5bff1e6f658f05476e8f2880804e44
|
||||
README.zh.md: 45f3536d3460106c45051093d097729a366eca1a
|
||||
@@ -87,4 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi
|
||||
- **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance).
|
||||
- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths.
|
||||
- **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code.
|
||||
- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. That matched the host-plane row this package started as (one per process); it becomes observable once several sessions run the same preset concurrently. Per-session temporary plugins would need the group and table keyed by the calling agent.
|
||||
- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. Several sessions running one preset concurrently is where that becomes observable. Per-session temporary plugins would need the group and table keyed by the calling agent.
|
||||
@@ -87,4 +87,4 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默
|
||||
- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。
|
||||
- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。
|
||||
- **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。
|
||||
- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。这与本包最初作为宿主平面行的语义一致(每进程一份);只有当多个会话并发运行同一 preset 时才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。
|
||||
- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。
|
||||
Reference in New Issue
Block a user