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:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user