From 065257addbc47eda65ab8eae2e3071d539b74c1b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 11:05:01 +0800 Subject: [PATCH] fix(agent-presets): bound the mount registry on a host that never reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records are pruned by observation rather than by a disposal hook, for the reason the module already states: three different owners can tear a subtree down, and a cleared `uid` is what they share. That leaves the pruning to whoever reads — and the only production reader is the invariant companion, whose package is a development composition a shipped host never loads. So a live host pruned nothing: every session ever composed left a record retaining its whole disposed subtree, since the fiber holds its config and that config is the key its EntryTree is stored under. Prune on the mount path too. Every session takes it, which bounds the set at one generation of dead records instead of one per session. --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +-- docs/capability-seams.md | 2 +- docs/config-catalog.md | 14 ++++----- docs/cordis-catalog/services.md | 22 +++++++------- docs/module-graph.md | 7 +++++ packages/README.i18n.yaml | 4 +-- .../cordis/tool-cordis/src/api-catalog.ts | 6 ++-- packages/preset/README.i18n.yaml | 4 +-- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/package.json | 4 +-- packages/preset/agent-presets/src/mount.ts | 29 ++++++++++++++++--- 11 files changed, 63 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 0f7bd16552..07a35fa473 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -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/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 408e5a52b15efde162fa1bc6ae1e9ede8a7f0d98 -2026-08-03-per-session-agent-presets.zh.md: a06aea8d7e89c77d374c06908c10a9b0e5029548 +2026-08-03-per-session-agent-presets.md: ee6303e5f52234d7eaf6768043c92a15e4e5399f +2026-08-03-per-session-agent-presets.zh.md: 5a2e1c3d8d0564f06c75785e1080b53740751355 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9099207264..d7963f28de 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -373,7 +373,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | - | [`tool-ask-user`](../packages/ui/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | -| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers profile directories over trusted and user-authored roots and mounts one profile cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 775387ca6f..5542d296f1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -115,25 +115,25 @@ Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loo Requires: `loader` ```ts config-catalog -/** Plugin config: which profile is the default, and where profiles live. */ +/** Plugin config: which preset is the default, and where presets live. */ export interface Config { - /** Profile id mounted when a caller names none. Missing at mount time fails loud. */ + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] } -/** One directory scanned for profile subdirectories. */ +/** One directory scanned for preset subdirectories. */ export interface PresetRoot { - /** Directory holding one subdirectory per profile; a leading `~` expands. */ + /** Directory holding one subdirectory per preset; a leading `~` expands. */ path: string - /** Trust recorded on every profile discovered under this root. */ + /** Trust recorded on every preset discovered under this root. */ trust: PresetTrust } /** - * Where a profile's composition came from. A `system` profile ships with the - * deployment; a `user` profile was authored locally, by a person or by an + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an * agent, and therefore carries the same trust as shell access. */ export type PresetTrust = 'system' | 'user' diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3030e2c348..0a70b0f69f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -50,33 +50,33 @@ Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent- Registry over the deployment's agent presets. -Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read. +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. ```ts cordis-catalog /** - * Every profile the configured roots currently supply. - * @returns the profiles, first-root-wins per id. + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. */ async list(): Promise /** - * Resolve one profile by id. - * @param id - the profile id, or `undefined` for {@link defaultId}. - * @returns the resolved profile. + * Resolve one preset by id. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. * @throws when no configured root supplies that id. */ async resolve(id?: string): Promise /** - * Compose one agent from a profile, installing it under that agent alone. + * Compose one agent from a preset, installing it under that agent alone. * * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken profile never yields a half-composed + * the agent creation back, so a broken preset never yields a half-composed * session. * @param agentCtx - the agent's scope context. - * @param id - the profile id, or `undefined` for {@link defaultId}. - * @returns the profile that was mounted, for the caller to record. - * @throws when the profile is unknown or its composition is unusable. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was mounted, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..d4680deebe 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -220,6 +220,9 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_preset["packages/preset"] + pkg_agent_presets["agent-presets"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -335,6 +338,9 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -1132,6 +1138,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 7980b809d6..3ef2ce6ee1 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: 365659617c97c44dd0f30fbcd3347b6438024eb3 -README.zh.md: 9edabd67ea728e77e2863a32c250675a5b9359f8 +README.md: b736aa5dc9d0e9313d652d40c3f4834456dccbb4 +README.zh.md: 53081b5e8c2d465dc866644eae78bebf0c4fc3a1 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 55a50977a7..d296b1b519 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -86,15 +86,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async list(): Promise', - jsDoc: '/**\n * Every profile the configured roots currently supply.\n * @returns the profiles, first-root-wins per id.\n */', + jsDoc: '/**\n * Every preset the configured roots currently supply.\n * @returns the presets, first-root-wins per id.\n */', }, { signature: 'async resolve(id?: string): Promise', - jsDoc: '/**\n * Resolve one profile by id.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the resolved profile.\n * @throws when no configured root supplies that id.\n */', + jsDoc: '/**\n * Resolve one preset by id.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the resolved preset.\n * @throws when no configured root supplies that id.\n */', }, { signature: 'async mount(agentCtx: Context, id?: string): Promise', - jsDoc: '/**\n * Compose one agent from a profile, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken profile never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the profile that was mounted, for the caller to record.\n * @throws when the profile is unknown or its composition is unusable.\n */', + jsDoc: '/**\n * Compose one agent from a preset, installing it under that agent alone.\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 mounted, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, ], }, diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index b554512392..f0176c1bc6 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -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/README.md -README.md: e7940642166f81e370e3a328f3097d15fd367151 -README.zh.md: 0767ca5074071e9ef2fa38769d27d8ef2344188e +README.md: 7baac391c224f717b60edeb0de828cb004ab460a +README.zh.md: 4d8c350b2831ae9c506ad7756ae157bf313a1c3e diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 9106494073..b27e89ec7b 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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: 6068a68d3c81081074165077a8afa6b42af48d1f -README.zh.md: 9f951f566a51a7b7acb666c7d9ea80061aa45d73 +README.md: 5d66c23f24717d1c30a9e729c7b0528715692d41 +README.zh.md: 9cdcd8b11a8a37d6d789c393e9a1500e4fbfd503 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 9dae4e584a..1411542d7c 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index ba09869cb4..39d55b17e6 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -60,16 +60,34 @@ export interface PresetMount { const mounts = new Set() /** - * Every preset composition still installed, pruning fibers disposed since the - * last read. Records are dropped lazily rather than through a disposal hook + * Drop every record whose subtree is gone. + * + * Records are pruned by observation rather than through a disposal hook * because a subtree can be torn down by its owning agent, by a failed mount, or * by the whole tree unloading, and a cleared `uid` is what all three share. - * @returns the live mounts. + * + * Pruning therefore has to happen on a path this module owns. Reading is one + * such path, but not a reliable one: the only production reader is the + * invariant companion's service listener, and `dsh-invariants` is a + * development composition — a shipped host never loads it. Mounting is the + * other, and it is the one every session takes, which bounds the set at one + * generation of dead records rather than one per session ever composed. Each + * record would otherwise retain its whole disposed subtree: the fiber holds + * its config, and that config is the key its `EntryTree` is stored under. */ -export function livePresetMounts(): PresetMount[] { +function pruneDisposedMounts(): void { for (const mount of mounts) { if (mount.fiber.uid === null) mounts.delete(mount) } +} + +/** + * Every preset composition still installed, pruning fibers disposed since the + * last read. + * @returns the live mounts. + */ +export function livePresetMounts(): PresetMount[] { + pruneDisposedMounts() return [...mounts] } @@ -167,6 +185,9 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi ) } const config: Include.Config = { path: pathToFileURL(preset.path).href } + // Before the record this mount is about to add: every session takes this + // path, so it is what keeps the set bounded on a host that never reads it. + pruneDisposedMounts() const handle = agentCtx.plugin(PresetTree, config) try { await handle.await()