From d1e22a7e247060496e1acdba9ef3be1700e23ec7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 21 Sep 2026 16:24:18 +0800 Subject: [PATCH] feat(preset): declare Agent compositions in profile YAML (#4569) * feat(preset): declare Agent compositions in profile YAML * fix(preset): share scope binding cleanup and refresh module graph * fix(preset): use host syntax colors in the YAML editor * fix(preset): write new profile declarations as block YAML * fix(preset): align eager activation with browser and worker surfaces * fix(ui-agent-preset): retain the existing preset card layout * fix(preset): keep rows waiting for a Host service mounted and re-audit after settlement A preset row whose Host provider was still activating at declaration time was reported as waiting for a missing service, and the registry recorded the definition as broken for the rest of the process. Split the mount audit into failed and pending rows: failures and root-realm leaks still reject the mount, while pending rows stay mounted and every read and binding re-audits them after the Host Loader tree settles. Remove the accidental self-dependency of @deepseek-ai/dsh-web-app and its lockfile self-link. Archive the copy-only preset authoring note, which the declarative-preset note fully supersedes, and update the ralph note's restore recipe to the profile-patch mechanism. * chore(lint): re-key and prune the unknown-cast baseline after the master merge * refactor(web-app): ship each Agent preset as its own bundle patch file `dsh.bundle.patch` accepts an ordered file list, so the four shipped preset declarations move out of the 1000-line web patch into `presets/.patch.yml`, one insert per file, applied after the host patch. The host patch keeps the preset registry row. Gates and test scaffolds that read the web layer resolve every patch file through `bundlePatchPaths` instead of one hardcoded path. * refactor(preset): drop the save notice injected into live Agents The notice duplicated the save result the Web editor already shows, and after the producer-owned message sources it would have needed its own persisted source kind. Saving now only persists and reports the activation diagnostic; the persistence catalog is unchanged from master. * docs: regenerate the module graph after dropping the registry's llm dependency * test(ui-agent-preset): cover the guide dialog's keyboard handling on the declarative section * fix(preset): address the remaining review comments Apply the lowercase id rule only when creating a declaration, so presets declared with other ids stay editable. Export `isBuiltInPreset` from the display fold and key the client card groups on it. Remove the dead `error` and `retry` locale keys, the JSDoc orphaned by the `trust` removal, the `trust` fields in the plugin-inventory test fixtures, and the stale `dsh-agent-presets` package name in comments and READMEs. * refactor(preset): make the Web preset page view-and-select only Remove the child-plugin YAML editor, the `agent_preset` tool and the registry's read/save Remote methods. Presets are declared and changed only by bundle patches; the settings page keeps the roster, the new-task default and the mode help, and its question icon explains that and offers to start a Creator-mode task. The `editing-cordis-compositions` skill and the Creator persona describe the bundle-based path, and the authored skill-load Session and Web snapshots are refreshed for it. * docs(skill): make the composition skill self-sufficient for preset authoring State where the shipped declarations live, list every declaration field, inline a complete minimal bundle and an override example, and name the verification calls, so Creator mode no longer reads DSH package sources to infer the format. Refresh the authored skill-load Session for the new text. * test(tools): drop the removed agent_preset tool from the catalog expectation --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 1 + ...2026-08-03-per-session-agent-presets.zh.md | 1 + .agents/notes/archived/manifest.json | 6 + ...08-08-copy-only-preset-authoring.i18n.yaml | 6 + .../2026-08-08-copy-only-preset-authoring.md | 1 + ...026-08-08-copy-only-preset-authoring.zh.md | 1 + ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...st-plane-ownership-after-presets.i18n.yaml | 4 +- ...8-10-host-plane-ownership-after-presets.md | 12 +- ...0-host-plane-ownership-after-presets.zh.md | 12 +- ...onsumer-owned-startup-strictness.i18n.yaml | 4 +- ...09-09-consumer-owned-startup-strictness.md | 2 +- ...09-consumer-owned-startup-strictness.zh.md | 2 +- ...09-18-declarative-agent-presets.i18n.yaml} | 6 +- .../2026-09-18-declarative-agent-presets.md | 39 + ...2026-09-18-declarative-agent-presets.zh.md | 39 + ...-agents-join-their-parent-preset.i18n.yaml | 4 +- ...0-child-agents-join-their-parent-preset.md | 4 +- ...hild-agents-join-their-parent-preset.zh.md | 4 +- ...12-ralph-off-in-shipped-defaults.i18n.yaml | 4 +- ...026-09-12-ralph-off-in-shipped-defaults.md | 4 +- ...-09-12-ralph-off-in-shipped-defaults.zh.md | 4 +- apps/cli/package.json | 10 +- apps/cli/tests/web-agent-presets.e2e.ts | 338 ++----- apps/cli/tests/windows-shell.spec.ts | 20 +- apps/desktop/scripts/test-host-updates.ts | 2 +- .../desktop/scripts/test-workspace-updates.ts | 2 +- apps/web/tests/agent-preset-authoring.e2e.ts | 323 +------ .../tests/agent-preset-authoring.overlay.yml | 13 - apps/web/tests/agent-preset-selection.e2e.ts | 47 +- apps/web/tests/assembled-boot.ts | 21 +- apps/web/tests/assembled-remote.ts | 3 +- .../authoring-help.expected.md | 6 + .../copy-dialog.expected.md | 13 - .../created.expected.md | 66 -- .../damaged.expected.md | 73 -- .../section.expected.md | 11 +- .../agent-preset-selection/menu.expected.md | 1 - .../new-session-refused/notice.expected.md | 2 +- .../fixtures/assembled-remote.fixture.json | 1 - apps/web/tests/minimal-preset.snapshot.ts | 2 +- apps/web/tests/new-session-refused.e2e.ts | 26 +- apps/web/tests/present.e2e.ts | 2 +- apps/web/tests/preset-migration.snapshot.ts | 2 +- apps/web/tests/ptc-escalation.e2e.ts | 2 +- apps/web/tests/ptc-round.e2e.ts | 2 +- apps/web/tests/scaffold-hermetic.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 42 +- apps/web/tests/schedule-after.e2e.ts | 10 +- apps/web/tests/seeded-history.e2e.ts | 8 +- apps/web/tests/settings-chrome.e2e.ts | 2 +- apps/web/tests/shipped-composition.e2e.ts | 2 +- apps/web/tests/skill-tool-row.e2e.ts | 2 +- benchmarks/package.json | 2 +- .../session-open/session-open.worker.ts | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 +- docs/capability-seams.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 65 +- docs/config-catalog.zh.md | 73 +- docs/dependency-catalog.json | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +- docs/event-producer-consumer.zh.md | 28 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 51 +- docs/module-graph.zh.md | 51 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 2 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 265 ++---- docs/subsystems/core.zh.md | 265 ++---- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 17 +- docs/subsystems/settings.zh.md | 17 +- packages/acp/acp/src/index.ts | 2 +- packages/api/remotes/package.json | 2 +- packages/api/remotes/src/client/index.ts | 6 +- packages/api/remotes/src/index.ts | 2 +- packages/api/remotes/tsconfig.client.json | 2 +- packages/api/remotes/tsconfig.host.json | 2 +- packages/api/session-controller/package.json | 4 +- packages/api/session-controller/src/agent.ts | 2 +- packages/api/session-controller/src/list.ts | 2 +- .../session-controller/src/skill-catalog.ts | 13 +- .../tests/agent.host.spec.ts | 2 +- .../tests/commands-create-fork.host.spec.ts | 2 +- .../tests/session-presets.host.spec.ts | 3 +- .../tests/session-projections.host.spec.ts | 2 +- .../tests/session-skills.host.spec.ts | 14 +- .../api/session-controller/tsconfig.host.json | 2 +- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 3 +- packages/api/settings-controller/README.zh.md | 3 +- packages/api/settings-controller/package.json | 4 +- packages/api/settings-controller/src/index.ts | 77 +- packages/api/settings-controller/src/types.ts | 5 - .../tests/settings-controller.host.spec.ts | 114 +-- .../api/settings-controller/tsconfig.json | 2 +- packages/bundle/headless/src/index.ts | 4 +- .../bundle/headless/tests/headless.spec.ts | 2 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 7 +- packages/bundle/web-app/README.zh.md | 7 +- packages/bundle/web-app/cordis.patch.yml | 14 +- packages/bundle/web-app/package.json | 59 +- .../bundle/web-app/presets/cordis.patch.yml | 154 +++ .../bundle/web-app/presets/minimal.patch.yml | 61 ++ packages/bundle/web-app/presets/ptc.patch.yml | 152 +++ .../bundle/web-app/presets/standard.patch.yml | 146 +++ .../connection/tests/node-half.host.spec.ts | 4 +- packages/client/tsdown.client.ts | 2 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 50 +- packages/client/ui-agent-preset/README.zh.md | 68 +- packages/client/ui-agent-preset/package.json | 15 +- .../src/client/AgentPresetLabel.tsx | 2 +- .../src/client/AgentPresetSeat.tsx | 11 +- .../src/client/AgentPresetSection.module.css | 159 +--- .../src/client/AgentPresetSection.tsx | 577 +++--------- .../ui-agent-preset/src/client/index.ts | 22 +- .../ui-agent-preset/src/client/locales.ts | 158 ++-- .../ui-agent-preset/src/client/seat-store.ts | 2 +- .../src/client/section-store.ts | 471 ++-------- .../src/client/settings-store.ts | 9 +- .../tests/apply.client.spec.ts | 102 +- .../tests/components.client.spec.tsx | 14 +- .../tests/locales.client.spec.ts | 12 +- .../tests/section-store.client.spec.ts | 750 +-------------- .../tests/section.client.spec.tsx | 856 ++++------------- .../tests/settings-store.client.spec.ts | 59 +- packages/client/ui-agent-preset/tsconfig.json | 2 +- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 2 +- .../ui-settings-plugin-inventory/README.zh.md | 2 +- .../ui-settings-plugin-inventory/package.json | 2 +- .../src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.tsx | 4 +- .../tests/components.client.spec.tsx | 15 +- .../tsconfig.json | 2 +- .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 2 +- .../client/ui-settings-plugins/README.zh.md | 2 +- .../agent-tool-presentation/README.i18n.yaml | 4 +- .../core/agent-tool-presentation/README.md | 6 +- .../core/agent-tool-presentation/README.zh.md | 6 +- .../core/agent-tool-presentation/src/index.ts | 2 +- .../tests/agent-tool-presentation.spec.ts | 2 +- .../tests/image-loadable.spec.ts | 4 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../webworker-runtime/src/storage/paths.ts | 2 +- .../webworker-runtime/src/worker-host.ts | 39 +- .../extensions/tool-cordis/src/api-catalog.ts | 173 ++-- .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/package.json | 6 +- packages/host/plugin-inventory/src/index.ts | 2 +- packages/host/plugin-inventory/src/types.ts | 2 - .../plugin-inventory/tests/inventory.spec.ts | 16 +- packages/host/plugin-inventory/tsconfig.json | 2 +- .../package.json | 6 +- .../src/index.ts | 4 +- .../tests/inventory.spec.ts | 20 +- .../tsconfig.json | 2 +- packages/preset/README.i18n.yaml | 4 +- packages/preset/README.md | 24 +- packages/preset/README.zh.md | 32 +- .../agent-preset-registry/README.i18n.yaml | 6 + .../preset/agent-preset-registry/README.md | 107 +++ .../preset/agent-preset-registry/README.zh.md | 107 +++ .../package.json | 19 +- .../src/composition-inventory.ts | 57 +- .../agent-preset-registry/src/definition.ts | 39 + .../src/display.ts | 18 +- .../preset/agent-preset-registry/src/index.ts | 364 ++++++++ .../src/invariant.ts | 26 +- .../preset/agent-preset-registry/src/mount.ts | 268 ++++++ .../agent-preset-registry/src/preset.ts | 15 + .../src/session.ts | 2 +- .../src/types.ts | 31 +- .../tests/composition-inventory.spec.ts | 27 + .../tests/display.spec.ts | 8 +- .../tests/fixtures/plugins/contribute.js | 0 .../tests/fixtures/plugins/global-service.js | 0 .../tests/fixtures/plugins/late-service.js | 0 .../tests/fixtures/plugins/needs-missing.js | 0 .../tests/fixtures/plugins/self-dispose.js | 0 .../tests/fixtures/plugins/throws.js | 6 + .../agent-preset-registry/tests/harness.ts | 47 + .../tests/invariant.spec.ts | 40 + .../agent-preset-registry/tests/mount.spec.ts | 72 ++ .../tests/registry.spec.ts | 334 +++++++ .../tests/session.spec.ts | 0 .../tsconfig.json | 17 +- .../README.i18n.yaml | 6 +- packages/preset/agent-preset/README.md | 92 ++ packages/preset/agent-preset/README.zh.md | 92 ++ packages/preset/agent-preset/package.json | 44 + .../skills/cordis-plugin-development/SKILL.md | 0 .../editing-cordis-compositions/SKILL.md | 80 ++ packages/preset/agent-preset/src/index.ts | 30 + packages/preset/agent-preset/tsconfig.json | 24 + packages/preset/agent-presets/README.md | 195 ---- packages/preset/agent-presets/README.zh.md | 195 ---- .../presets/cordis/agent.cordis.yml | 270 ------ .../agent-presets/presets/cordis/preset.yml | 3 - .../editing-cordis-compositions/SKILL.md | 102 -- .../presets/minimal/agent.cordis.yml | 69 -- .../agent-presets/presets/minimal/preset.yml | 3 - .../presets/ptc/agent.cordis.yml | 287 ------ .../agent-presets/presets/ptc/preset.yml | 3 - .../presets/standard/agent.cordis.yml | 266 ------ .../agent-presets/presets/standard/preset.yml | 3 - .../preset/agent-presets/src/authoring.ts | 191 ---- .../preset/agent-presets/src/discovery.ts | 337 ------- packages/preset/agent-presets/src/index.ts | 862 ----------------- packages/preset/agent-presets/src/metadata.ts | 105 --- packages/preset/agent-presets/src/mount.ts | 424 --------- packages/preset/agent-presets/src/preset.ts | 70 -- .../preset/agent-presets/src/specifier.ts | 49 - .../agent-presets/tests/authoring.spec.ts | 330 ------- .../tests/composition-inventory.spec.ts | 421 --------- .../agent-presets/tests/discovery.spec.ts | 371 -------- .../tests/fixtures/plugins/throws.js | 9 - .../fixtures/system/minimal/agent.cordis.yml | 4 - .../fixtures/system/standard/agent.cordis.yml | 12 - .../fixtures/user/broken/agent.cordis.yml | 8 - .../fixtures/user/isolated/agent.cordis.yml | 9 - .../tests/fixtures/user/late/agent.cordis.yml | 6 - .../fixtures/user/leaky/agent.cordis.yml | 13 - .../user/nested-broken/agent.cordis.yml | 16 - .../fixtures/user/not-a-preset/notes.txt | 1 - .../fixtures/user/pending/agent.cordis.yml | 2 - .../fixtures/user/standard/agent.cordis.yml | 5 - .../fixtures/user/two-broken/agent.cordis.yml | 11 - .../agent-presets/tests/invariant.spec.ts | 141 --- .../agent-presets/tests/metadata.spec.ts | 121 --- .../preset/agent-presets/tests/mount.spec.ts | 876 ------------------ .../preset/agent-presets/tests/remote.spec.ts | 473 ---------- .../agent-presets/tests/settings.spec.ts | 191 ---- .../agent-presets/tests/shipped-root.spec.ts | 176 ---- .../agent-presets/tests/user-root.spec.ts | 141 --- packages/preset/persona/README.i18n.yaml | 4 +- packages/preset/persona/README.md | 2 +- packages/preset/persona/README.zh.md | 2 +- .../invariants/README.i18n.yaml | 4 +- .../runtime-diagnostics/invariants/README.md | 2 +- .../invariants/README.zh.md | 2 +- packages/sdk/server/src/server.ts | 2 +- .../subagent-in-process-driver/package.json | 2 +- .../tests/preset-inheritance.spec.ts | 8 +- packages/subagent/subagent/package.json | 6 +- packages/subagent/subagent/src/child-agent.ts | 2 +- packages/subagent/subagent/tsconfig.json | 2 +- .../src/assembly/remote-default-responses.ts | 2 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- packages/webhook/webhook/package.json | 4 +- packages/webhook/webhook/src/session.ts | 5 +- .../webhook/webhook/tests/runtime.spec.ts | 2 +- .../webhook/webhook/tests/session.spec.ts | 4 +- packages/webhook/webhook/tsconfig.json | 2 +- pnpm-lock.yaml | 181 +++- python/sdk-runtime/package.json | 13 +- scripts/check-workspace-constraints.ts | 4 +- scripts/cordis-yaml.ts | 24 + scripts/dependency-catalog/package-lock.json | 24 +- scripts/gen-cordis-catalog.ts | 12 +- scripts/gen-doc-graphs.ts | 4 +- scripts/no-unknown-casts.baseline.json | 29 +- scripts/rescope-vendor.ts | 13 +- scripts/verify-config-source-ownership.ts | 1 + scripts/verify-cordis-config.ts | 49 +- .../verify-default-product-isolation.spec.ts | 9 +- scripts/verify-default-product-isolation.ts | 5 +- .../verify-package-readme-model-experience.ts | 2 +- scripts/verify-runtime-closure.spec.ts | 20 +- scripts/verify-runtime-closure.ts | 36 +- snapshots/session/headless.snapshot.ts | 2 +- snapshots/session/skill-load/session.v3.jsonl | 4 +- snapshots/session/skill-load/session.v4.jsonl | 2 +- snapshots/web/skill-tool-row/ui.expected.md | 2 +- tsconfig.base.json | 10 +- tsconfig.host.json | 3 +- 294 files changed, 4120 insertions(+), 11921 deletions(-) rename .agents/notes/{implemented => archived}/architecture/2026-08-03-per-session-agent-presets.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/architecture/2026-08-03-per-session-agent-presets.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-08-03-per-session-agent-presets.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-08-08-copy-only-preset-authoring.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-08-08-copy-only-preset-authoring.zh.md (99%) rename .agents/notes/implemented/{simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml => architecture/2026-09-18-declarative-agent-presets.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md create mode 100644 .agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md delete mode 100644 apps/web/tests/agent-preset-authoring.overlay.yml create mode 100644 apps/web/tests/expected/agent-preset-authoring/authoring-help.expected.md delete mode 100644 apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md delete mode 100644 apps/web/tests/expected/agent-preset-authoring/created.expected.md delete mode 100644 apps/web/tests/expected/agent-preset-authoring/damaged.expected.md create mode 100644 packages/bundle/web-app/presets/cordis.patch.yml create mode 100644 packages/bundle/web-app/presets/minimal.patch.yml create mode 100644 packages/bundle/web-app/presets/ptc.patch.yml create mode 100644 packages/bundle/web-app/presets/standard.patch.yml create mode 100644 packages/preset/agent-preset-registry/README.i18n.yaml create mode 100644 packages/preset/agent-preset-registry/README.md create mode 100644 packages/preset/agent-preset-registry/README.zh.md rename packages/preset/{agent-presets => agent-preset-registry}/package.json (85%) rename packages/preset/{agent-presets => agent-preset-registry}/src/composition-inventory.ts (71%) create mode 100644 packages/preset/agent-preset-registry/src/definition.ts rename packages/preset/{agent-presets => agent-preset-registry}/src/display.ts (80%) create mode 100644 packages/preset/agent-preset-registry/src/index.ts rename packages/preset/{agent-presets => agent-preset-registry}/src/invariant.ts (69%) create mode 100644 packages/preset/agent-preset-registry/src/mount.ts create mode 100644 packages/preset/agent-preset-registry/src/preset.ts rename packages/preset/{agent-presets => agent-preset-registry}/src/session.ts (96%) rename packages/preset/{agent-presets => agent-preset-registry}/src/types.ts (61%) create mode 100644 packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts rename packages/preset/{agent-presets => agent-preset-registry}/tests/display.spec.ts (68%) rename packages/preset/{agent-presets => agent-preset-registry}/tests/fixtures/plugins/contribute.js (100%) rename packages/preset/{agent-presets => agent-preset-registry}/tests/fixtures/plugins/global-service.js (100%) rename packages/preset/{agent-presets => agent-preset-registry}/tests/fixtures/plugins/late-service.js (100%) rename packages/preset/{agent-presets => agent-preset-registry}/tests/fixtures/plugins/needs-missing.js (100%) rename packages/preset/{agent-presets => agent-preset-registry}/tests/fixtures/plugins/self-dispose.js (100%) create mode 100644 packages/preset/agent-preset-registry/tests/fixtures/plugins/throws.js create mode 100644 packages/preset/agent-preset-registry/tests/harness.ts create mode 100644 packages/preset/agent-preset-registry/tests/invariant.spec.ts create mode 100644 packages/preset/agent-preset-registry/tests/mount.spec.ts create mode 100644 packages/preset/agent-preset-registry/tests/registry.spec.ts rename packages/preset/{agent-presets => agent-preset-registry}/tests/session.spec.ts (100%) rename packages/preset/{agent-presets => agent-preset-registry}/tsconfig.json (81%) rename packages/preset/{agent-presets => agent-preset}/README.i18n.yaml (70%) create mode 100644 packages/preset/agent-preset/README.md create mode 100644 packages/preset/agent-preset/README.zh.md create mode 100644 packages/preset/agent-preset/package.json rename packages/preset/{agent-presets/presets/cordis => agent-preset}/skills/cordis-plugin-development/SKILL.md (100%) create mode 100644 packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md create mode 100644 packages/preset/agent-preset/src/index.ts create mode 100644 packages/preset/agent-preset/tsconfig.json delete mode 100644 packages/preset/agent-presets/README.md delete mode 100644 packages/preset/agent-presets/README.zh.md delete mode 100644 packages/preset/agent-presets/presets/cordis/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/presets/cordis/preset.yml delete mode 100644 packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md delete mode 100644 packages/preset/agent-presets/presets/minimal/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/presets/minimal/preset.yml delete mode 100644 packages/preset/agent-presets/presets/ptc/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/presets/ptc/preset.yml delete mode 100644 packages/preset/agent-presets/presets/standard/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/presets/standard/preset.yml delete mode 100644 packages/preset/agent-presets/src/authoring.ts delete mode 100644 packages/preset/agent-presets/src/discovery.ts delete mode 100644 packages/preset/agent-presets/src/index.ts delete mode 100644 packages/preset/agent-presets/src/metadata.ts delete mode 100644 packages/preset/agent-presets/src/mount.ts delete mode 100644 packages/preset/agent-presets/src/preset.ts delete mode 100644 packages/preset/agent-presets/src/specifier.ts delete mode 100644 packages/preset/agent-presets/tests/authoring.spec.ts delete mode 100644 packages/preset/agent-presets/tests/composition-inventory.spec.ts delete mode 100644 packages/preset/agent-presets/tests/discovery.spec.ts delete mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/throws.js delete mode 100644 packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml delete mode 100644 packages/preset/agent-presets/tests/invariant.spec.ts delete mode 100644 packages/preset/agent-presets/tests/metadata.spec.ts delete mode 100644 packages/preset/agent-presets/tests/mount.spec.ts delete mode 100644 packages/preset/agent-presets/tests/remote.spec.ts delete mode 100644 packages/preset/agent-presets/tests/settings.spec.ts delete mode 100644 packages/preset/agent-presets/tests/shipped-root.spec.ts delete mode 100644 packages/preset/agent-presets/tests/user-root.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml similarity index 68% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 5d89e6b732..fc866f6f10 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/archived/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: 47f2e28c2aac583ce4ca810157e9c7abe58256d5 -2026-08-03-per-session-agent-presets.zh.md: 88b643ea710364f18ff7c73a8ca0e10de0fbb44c +2026-08-03-per-session-agent-presets.md: a1f2ad294edfdaaf2dab2d02e858e4dcb0a0c746 +2026-08-03-per-session-agent-presets.zh.md: b745c22c91b4017131a32796c085a869a2eabaf0 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md index 47f2e28c2a..a1f2ad294e 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md @@ -1,6 +1,7 @@ # Agent Note: A session's agent is composed from a preset cordis.yml Status: implemented +Archived: 2026-09-18 English | [中文](2026-08-03-per-session-agent-presets.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md index 88b643ea71..b745c22c91 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -1,6 +1,7 @@ # Agent Note: 会话的 agent 由一份 preset cordis.yml 组装而成 Status: implemented +Archived: 2026-09-18 [English](2026-08-03-per-session-agent-presets.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index ca509c1070..6fc82fe8a6 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -160,6 +160,9 @@ "architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml": "sha256:43c9128079955e974fe1da3ce1a10779b4f5a25ac3629f92e3b1de847ad45050", "architecture/2026-08-01-packaged-ripgrep-search.md": "sha256:36cf2d6daa654abced690965338aec4628069dc77be3a1da6368a9b097a59ff4", "architecture/2026-08-01-packaged-ripgrep-search.zh.md": "sha256:45b2fe8e0d492eeff313d2ef3c98fd69761b3257eee3476b8bf53312e8b6095f", + "architecture/2026-08-03-per-session-agent-presets.i18n.yaml": "sha256:3fe057a67e41f9ce4f5cc8b162e264494b39169588fe9c9e48dcf95fd1b208a1", + "architecture/2026-08-03-per-session-agent-presets.md": "sha256:d4c6c6af11f41b57e95ca359e2cbe09780cb0851037998824a5966cab5e12626", + "architecture/2026-08-03-per-session-agent-presets.zh.md": "sha256:c91248b2d9cc5c51c77e9d195aca5d51a06684971b1afafc60b2def72248afca", "architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml": "sha256:c1a59c3c04afada633740c808b98e765ccad09220c4c79d8f4207313c9e9aab6", "architecture/2026-08-03-pi-ai-declared-provider-catalog.md": "sha256:fae0c59ece887f7d9344b6254d20cc671894074d53eefffb85dbf45b9104d2b6", "architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md": "sha256:8032cefedd51b292d810051ff32e8825958e6d90b2e523a374294ef2f859ca1e", @@ -1780,6 +1783,9 @@ "simplification/2026-08-06-user-bubbles-drop-the-branch-action.i18n.yaml": "sha256:e000606d801984d4ee473779891ffe8c197f64f54b75efd82fb46dc84217cd59", "simplification/2026-08-06-user-bubbles-drop-the-branch-action.md": "sha256:b93ce0d79ee72306d569d80fd81308a2b5704d95df8854b7cbd2f3d898b3f776", "simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md": "sha256:999e3e1252f771c420506a80b7c0d512b70da6770e3e62bb92169180b4cee3e8", + "simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml": "sha256:43bba45597b198a94e3cbf8df31794239c521ecf002338636dca2c099acd94e1", + "simplification/2026-08-08-copy-only-preset-authoring.md": "sha256:be77d385a3c4db534fbbd5b4a830fcb7f6bcd8401221617859569e2c4d837069", + "simplification/2026-08-08-copy-only-preset-authoring.zh.md": "sha256:4c544a08e741c4a273a89dc2a3fda44dffb46af15d0feb8f7bf8efa816bfbd13", "simplification/2026-08-08-remove-cli-demo.i18n.yaml": "sha256:1baa8649e186e159afb03f112059f46f7a35c303a6039374db8bc4ca743d4bdf", "simplification/2026-08-08-remove-cli-demo.md": "sha256:a34ce79b674c67a6a47d99c1fa7141b2ae58e36b99da5989d323d229d1921baa", "simplification/2026-08-08-remove-cli-demo.zh.md": "sha256:96a2a7cc34c7b0abf71b77d666a6349d681fd8a0d9a9c81e30d2aa538d62797a", diff --git a/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..0af0acb43a --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/archived/simplification/2026-08-08-copy-only-preset-authoring.md +2026-08-08-copy-only-preset-authoring.md: 07f8a6553cf9c70d5289a074b6e6d0d677dd4a28 +2026-08-08-copy-only-preset-authoring.zh.md: 58757a9774354b116d235f890beb136034f29904 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md rename to .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md index 13d0bfc7c4..07f8a6553c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md @@ -1,6 +1,7 @@ # Agent Note: Copy-only preset authoring, and the way into a preset's files Status: implemented +Archived: 2026-09-20 English | [中文](2026-08-08-copy-only-preset-authoring.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md rename to .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 2b93ac1b81..58757a9774 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -1,6 +1,7 @@ # Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 Status: implemented +Archived: 2026-09-20 [English](2026-08-08-copy-only-preset-authoring.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 6a0f5d94c7..e09f7db568 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7260d11c84f8fb07cf6c4691357f92e0d00fd8f3 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 653fb3d9b782d9010f693e4af8ad94475abb9d94 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index ff00327c38..7260d11c84 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,7 +40,7 @@ The Python client supplies an explicit Harness home and selects the `sdk` profil Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/bundle/web-app/cordis.patch.yml*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 3f7f18a990..653fb3d9b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,7 +40,7 @@ Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 pa exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/bundle/web-app/cordis.patch.yml*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml index 79ed44e6a0..4cc71b035b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-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-10-host-plane-ownership-after-presets.md -2026-08-10-host-plane-ownership-after-presets.md: 78410368cdc66b14fe478a4de9f0ae7a1cbefac5 -2026-08-10-host-plane-ownership-after-presets.zh.md: 862b793fab23b209f6f43b2e8045ad7330c30ba0 +2026-08-10-host-plane-ownership-after-presets.md: daf48f27c2228b71319d6f5410e6cabe3bc02b39 +2026-08-10-host-plane-ownership-after-presets.zh.md: 56babf5e715584b4ee061a334158d46e59b0d350 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md index 78410368cd..daf48f27c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md) ## Problem -[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). +[Per-session agent presets](2026-09-18-declarative-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). Two more readers were still on the wrong side of that line. @@ -18,15 +18,15 @@ Nothing named an agent that joined no preset. The join is a scope-parent link; w **The meter is host-plane.** `dsh-token-meter` returns to the host composition and leaves the presets' `isolate` map, so `compaction-basic` and `tool-result-pruner` resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion `tasks` and `goals` are already read by, applied to a Service whose *projection* reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process. -**An unjoined agent is named twice, at two different points.** `AgentPresets` logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at `system-prompt/assemble`, not at publication, because an unjoined agent is legal until it addresses a model: `recompose` binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range. +**An unjoined agent fails at model use.** The invariant companion checks `system-prompt/assemble`, because a bare Agent can legally be bound later through `recompose`. Host-only and cold-scope prompt reads have no Agent and remain outside this check. -Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal ([`dsh-session-projection`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.md)). +Projection key presence is not a per-session capability signal ([session projection](../../../../packages/session/session-projection/README.md)). Temporary plugins mounted through `cordis_mount` belong to the composition rather than the invoking session ([tool Cordis](../../../../packages/extensions/tool-cordis/README.md)). [Declarative presets](2026-09-18-declarative-agent-presets.md) own revision retention and reclaim retired trees after their final reference releases. ## Testing -`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. +`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any Agent is created — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. -`packages/preset/agent-presets/tests/mount.spec.ts` asserts the warning fires exactly once for a bare agent and not at all for a joined one. `tests/invariant.spec.ts` carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass. +`packages/preset/agent-preset-registry/tests/invariant.spec.ts` rejects an unjoined Agent's assembly and accepts a joined Agent, a Host read and a cold-scope read. ## Alternatives considered @@ -42,4 +42,4 @@ Three limits stay open and are recorded where they bite rather than fixed here: The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. -The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load `dsh-invariants`, which fences package tests and development hosts, not a shipped one. +The invariant reaches compositions loading `dsh-invariants`. Production entry points must compose Agents explicitly; installing the registry alone does not bind them. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md index 862b793fab..56babf5e71 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[逐会话 agent preset](2026-08-03-per-session-agent-presets.zh.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md))。 +[逐会话 agent preset](2026-09-18-declarative-agent-presets.zh.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md))。 还有两个读取点仍站在这条线的错误一侧。 @@ -18,15 +18,15 @@ Status: implemented **meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compaction-basic` 与 `tool-result-pruner` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks` 与 `goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。 -**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。 +**未加入的 Agent 在使用模型时失败。** 不变量 companion 检查 `system-prompt/assemble`,因为未绑定的 Agent 可以合法地稍后通过 `recompose` 绑定。纯 Host 和历史 scope 提示词读取没有 Agent,不属于此检查范围。 -有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.zh.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.zh.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.zh.md))。 +投影 key 是否存在不能代表逐会话能力([会话投影](../../../../packages/session/session-projection/README.zh.md))。通过 `cordis_mount` 挂载的临时插件属于组装,而非调用会话([Cordis 工具](../../../../packages/extensions/tool-cordis/README.zh.md))。[声明式 preset](2026-09-18-declarative-agent-presets.zh.md) 负责代际保留,在最后一个引用释放后回收退役树。 ## 测试 -`apps/cli/tests/web-agent-presets.e2e.ts` 在本文件中任何 preset 挂载**之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 +`apps/cli/tests/web-agent-presets.e2e.ts` 在创建任何 Agent **之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 -`packages/preset/agent-presets/tests/mount.spec.ts` 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。`tests/invariant.spec.ts` 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。 +`packages/preset/agent-preset-registry/tests/invariant.spec.ts` 拒绝未加入的 Agent 组装,并接受已加入的 Agent、Host 读取和历史 scope 读取。 ## 考虑过的替代方案 @@ -42,4 +42,4 @@ Status: implemented context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 -那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。 +不变量覆盖加载了 `dsh-invariants` 的组装。生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index b44324b5dd..bf6ed1340c 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.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-09-09-consumer-owned-startup-strictness.md -2026-09-09-consumer-owned-startup-strictness.md: 2f16078c7051b4038c1d48120b500f0fcd465aef -2026-09-09-consumer-owned-startup-strictness.zh.md: c0fe61e5f708f79f6a5b903f3157ee358c3086bc +2026-09-09-consumer-owned-startup-strictness.md: bbe975e13b251e2e0d18c4e002efbf1d362548f3 +2026-09-09-consumer-owned-startup-strictness.zh.md: 4bd2bfe026a13ccbd46215d2c0165b5a8c5327f7 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index 2f16078c70..bbe975e13b 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -18,7 +18,7 @@ The audit treats a throwing `disabled` expression as an entry failure, not a dis The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair. -This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-08-03-per-session-agent-presets.md) own a separate strict subtree audit. +This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index c0fe61e5f7..4bd2bfe026 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -18,7 +18,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles 该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。 -该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-08-03-per-session-agent-presets.zh.md)持有独立的严格子树审计。 +该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml similarity index 56% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml index 505c69b9b3..8c41c47eeb 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: 13d0bfc7c49d4b97b6c444fe1ff6c6455fef53ec -2026-08-08-copy-only-preset-authoring.zh.md: 2b93ac1b81ddb2461308baffb3b6563fe240f9d5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md +2026-09-18-declarative-agent-presets.md: 55ec173b33b7ddd62c88c68afc8b7546074aae0a +2026-09-18-declarative-agent-presets.zh.md: 60c81de4d981818f805b1014c2e3f05f16d1e8a9 diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md new file mode 100644 index 0000000000..55ec173b33 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md @@ -0,0 +1,39 @@ +# Agent Note: Declarative Agent presets and retained revisions + +Status: implemented + +English | [中文](2026-09-18-declarative-agent-presets.zh.md) + +## Problem + +Preset directories duplicate Cordis configuration ownership. Separate discovery, metadata, copying and editing APIs cannot express the same composition through an ordinary profile patch. Disposing a definition during a save must also preserve plugins still used by running Agents. + +## Decision + +`dsh-agent-preset-registry` owns selection and runtime revisions; `dsh-agent-preset` declares an identity, display metadata and child plugin list in ordinary Cordis YAML. Definitions activate eagerly. Activation failures remain in the roster and reject new bindings without preventing the application from starting. + +The registry owns each revision's scope and Loader tree. Agents link their own scopes to the selected revision, and children inherit their parent's exact revision. Updates retire the former revision; Agents and temporary cold readers retain references until disposal. The final release disposes a retired tree. Host registries and the Agent loop remain shared; preset service providers and consumers require an isolated realm. + +The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host. + +Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The existing chooser policy and blank-session selection remain: hiding the chooser uses the deployment default without deleting the saved user preference. + +## Alternatives considered + +**Keep directory presets alongside declarations.** Two writable sources would compete for an identity and require precedence, migration and editing rules. Ordinary profile configuration supplies the required persistence and layering, so presets have no separate paths. + +**Let each declaring plugin own its live child tree.** Loader disposal during an edit would revoke tools from running Agents. Registry ownership gives retained revisions an explicit lifetime independent of the declaration. + +**Load only on first selection.** Lazy activation delays diagnostics and adds pending-first-use state. Eager activation is acceptable for these compositions and exposes failures before selection. + +**Reject application startup for a broken preset.** A failed optional capability set should remain repairable in Web. Healthy definitions and Host services remain available. + +## Consequences + +One process can run Agents with different capabilities while sharing each selected revision. Retired revisions remain in memory while referenced. Presets do not provide a security sandbox, and a user override replaces the entire child list rather than merging future builtin changes. + +The former directory and per-session-mount decision is preserved as [historical context](../../archived/architecture/2026-08-03-per-session-agent-presets.md). Host service ownership remains documented in [the Host-plane note](2026-08-10-host-plane-ownership-after-presets.md). + +## Testing + +Registry tests cover retained parent and child scopes, cold-reader leases, selection logging and failed activation. Editor tests cross real profile patches, check conflict rejection and preserve nested expressions. The shipped Web composition test checks activation, scoped tools, Creator skills and profile edits against built packages. diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md new file mode 100644 index 0000000000..60c81de4d9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 声明式 Agent preset 与保留代际 + +Status: implemented + +[English](2026-09-18-declarative-agent-presets.md) | 中文 + +## Problem + +Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数据、复制和编辑 API 无法通过普通 profile 补丁表达相同组装。保存时释放定义,也必须保留运行中 Agent 仍在使用的插件。 + +## Decision + +`dsh-agent-preset-registry` 负责选择与运行时代际;`dsh-agent-preset` 在普通 Cordis YAML 中声明标识、显示元数据和子插件列表。定义提前激活。激活失败保留在列表中,并拒绝新绑定,但不阻止应用启动。 + +注册表拥有各代际的 scope 与 Loader 树。Agent 将自身 scope 链接到选定代际,子 Agent 继承父方的确切代际。更新使旧代际退役;Agent 和临时历史读取者保留引用,直至释放。最后一个引用释放后,退役树被销毁。Host 注册表和 Agent loop 保持共享;preset 的服务提供方与消费者需要处于隔离 realm 中。 + +Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。 + +会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。现有选择器策略和空白会话选择保持不变:隐藏选择器使用部署默认值,但不删除保存的用户偏好。 + +## Alternatives considered + +**同时保留目录 preset 与声明。** 两个可写来源会争用标识,需要额外的优先级、迁移和编辑规则。普通 profile 配置已提供所需持久化和分层,因此 preset 不再有独立路径。 + +**让声明插件拥有运行中的子树。** 编辑时 Loader 释放插件会撤销运行中 Agent 的工具。注册表所有权使保留代际具有独立于声明的明确生命周期。 + +**首次选择时才加载。** 延迟激活推迟诊断,并增加首次使用的等待状态。提前激活适用于这些组装,且可在选择前暴露失败。 + +**Preset 损坏就拒绝应用启动。** 可选能力集失败后应仍能在 Web 中修复。健康定义和 Host 服务保持可用。 + +## Consequences + +同一进程可以运行能力不同的 Agent,同时共享各选定代际。退役代际在仍有引用时占用内存。Preset 不提供安全沙箱,用户覆盖会替换整个子插件列表,不会合并后续内置变更。 + +原有目录与逐会话挂载决策保留为[历史背景](../../archived/architecture/2026-08-03-per-session-agent-presets.md)。Host 服务所有权继续由 [Host 平面说明](2026-08-10-host-plane-ownership-after-presets.zh.md)记录。 + +## Testing + +注册表测试覆盖父子 scope 保留、历史读取租约、选择日志及激活失败。编辑器测试使用真实 profile 补丁,检查冲突拒绝并保留嵌套表达式。随附 Web 组装测试通过构建产物验证激活、作用域工具、Creator 技能和 profile 编辑。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 622596ee8c..027bf3babb 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: 5321d99bad35cf26ebc0e7842dc68c2775bc384b -2026-08-10-child-agents-join-their-parent-preset.zh.md: f0d5b59c3787ef69c2c983da0c8ec0ff4a43e6e7 +2026-08-10-child-agents-join-their-parent-preset.md: 7de247a7a3f75f467ebf5c8387eb8fb8e2f7e009 +2026-08-10-child-agents-join-their-parent-preset.zh.md: c9d392cd75ee9c06005aa53f4f68a8153fc7633b diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index 5321d99bad..7de247a7a3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-child-agents-join-their-parent-preset.zh.md) ## Problem -Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. +Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-09-18-declarative-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. That layer is now empty in any deployment with a preset roster: the web-app patch layer disables every host-plane tool row. A one-shot child therefore reached the model with zero tools, a continuable child with only the host-plane `report`, and neither carried its parent's persona, workspace context, plan-mode section, or skill catalog. The fork path had already been given the same treatment for the same reason; delegation had not. @@ -40,7 +40,7 @@ Giving the child its parent's tools exposed a second defect the same agent-plane ## Testing -`packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. +`packages/preset/agent-preset-registry/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. `packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index f0d5b59c37..c9d392cd75 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-08-03-per-session-agent-presets.zh.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 +工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-09-18-declarative-agent-presets.zh.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 在任何配置了 preset roster 的部署里,那一层现在是空的:web-app 补丁层禁用了全部宿主平面工具行。因此一次性子 agent 抵达模型时工具为零,可继续子 agent 只剩宿主平面的 `report`,两者都不带父方的 persona、工作区上下文、plan-mode 段与技能目录。fork 路径此前已因同一理由做过相同处理;委派没有。 @@ -40,7 +40,7 @@ Status: implemented ## 测试 -`packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 +`packages/preset/agent-preset-registry/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 `packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域。 diff --git a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml index 3410256a1a..9d38b3b53b 100644 --- a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.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/simplification/2026-09-12-ralph-off-in-shipped-defaults.md -2026-09-12-ralph-off-in-shipped-defaults.md: 4ae434a0e9d6283c2934cdf74a562df360daa12e -2026-09-12-ralph-off-in-shipped-defaults.zh.md: 490b33d85be411826d03c5def08b1905062ff830 +2026-09-12-ralph-off-in-shipped-defaults.md: fbd7ea72e02302f45029d2b7677d9a4d15ee2e63 +2026-09-12-ralph-off-in-shipped-defaults.zh.md: 377f8656236b55dc8de79a78abe742b595e3617c diff --git a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md index 4ae434a0e9..fbd7ea72e0 100644 --- a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md +++ b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md @@ -16,7 +16,7 @@ That tool shipped enabled in `packages/bundle/base/cordis.patch.yml` and in thre The `ptc` preset also declares `workflow-ptc` disabled. That preset kept the engine for `ralph` alone after it dropped the general `workflow` tool, so disabling `ralph` left the engine with no consumer in that composition. -Each disabled row carries its restore recipe in a local comment. An overlay row re-enables the capability for a base-backed profile from `$DSH_HOME/cordis.patch.yml` or a `--patch` file. Preset files take no patches (`packages/preset/agent-presets/README.md`), so a Web session that wants `ralph` duplicates the preset under a **new id** into `$DSH_HOME/.agent-presets` and drops `disabled`; a copy reusing the shipped id is shadowed by the shipped root, which wins duplicate ids (`packages/preset/agent-presets/src/index.ts`), and `copy()` refuses an id any root already supplies. In `ptc` the duplicate drops `disabled` from the tool row and the engine row together, because `tool-ralph` injects `ctx.workflowEngine`. +Each disabled row carries its restore recipe in a local comment. An overlay row re-enables the capability for a base-backed profile from `$DSH_HOME/cordis.patch.yml` or a `--patch` file. Shipped presets are `@deepseek-ai/dsh-agent-preset` rows in the Web bundle patch (`packages/bundle/web-app/cordis.patch.yml`), so a Web session that wants `ralph` overrides that row's `config.plugins` from the profile patch, or saves a preset under a **new id** through the Web editor, which persists the declaration in `$DSH_HOME/profiles//cordis.patch.yml`; the registry refuses a duplicate preset id (`packages/preset/agent-preset-registry/src/index.ts`). In `ptc` the override drops `disabled` from the tool row and the engine row together, because `tool-ralph` injects `ctx.workflowEngine`. `packages/bundle/web-app/cordis.patch.yml` restates that disable for its own layer. `validatePresetPlaneSeparation` in `scripts/verify-cordis-config.ts` collects declared row ids without regard to `disabled`, so deleting that row would return `tool-ralph` to the Web host plane and collide with the same id in every preset. @@ -44,6 +44,6 @@ A default Web, headless, sdk, acp, or custom base-backed session no longer offer ## Verification -`packages/preset/agent-presets/tests/shipped-root.spec.ts` pins that every preset carrying `tool-ralph` disables it, that the `minimal` roster carries no such row, that `ptc` disables `workflow-ptc`, and that `standard` and `cordis` keep the engine enabled for their `workflow` tool. `apps/cli/tests/web-agent-presets.e2e.ts` and `apps/web/tests/shipped-composition.e2e.ts` pin the exact default and PTC tool catalogs, so a row that stops contributing is a test failure rather than a silently shorter list. `scripts/verify-cordis-config.ts` continues to pass its plane-separation check against the disabled rows. +`apps/cli/tests/web-agent-presets.e2e.ts` pins that every preset carrying `tool-ralph` disables it, that the `minimal` roster carries no such row, that `ptc` disables `workflow-ptc`, and that `standard` and `cordis` keep the engine enabled for their `workflow` tool. `apps/cli/tests/web-agent-presets.e2e.ts` and `apps/web/tests/shipped-composition.e2e.ts` pin the exact default and PTC tool catalogs, so a row that stops contributing is a test failure rather than a silently shorter list. `scripts/verify-cordis-config.ts` continues to pass its plane-separation check against the disabled rows. Most affected recorded-session sidecars were regenerated with `DSH_SNAPSHOT=refresh pnpm run test:snapshot`, and `pnpm run test:snapshot` replays the corpus. Six sidecars were curated by hand instead, because no local run produces them. Four belong to `pwsh-tool-turn` and `persistent-pwsh-tool-turn`, which this host skips for a missing `pwsh`; their removed text is byte-identical to what the refresh removed elsewhere. The remaining two are `snapshots/web/schedule-catalog`, which no executing test writes or reads: `schedule-after.e2e.ts` reads only that directory's `catalog.expected.md` and `session.v3.jsonl`, and `assertFixtureInventory` checks only that the four files exist, so the `header.pin: true` in its `snapshot.yml` is not enforced. `snapshots/session/ralph-loop` passes under its own composition patch, which is the evidence that the demotion removed the row from the defaults without removing coverage of the tool. diff --git a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md index 490b33d85b..377f865623 100644 --- a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md @@ -16,7 +16,7 @@ Status: implemented `ptc` preset 还额外把 `workflow-ptc` 声明为禁用。该 preset 在去掉通用 `workflow` 工具之后只为 `ralph` 保留了这个引擎,因此禁用 `ralph` 使该组合中的引擎不再有消费方。 -每个被禁用的行都在本地注释里带上恢复方法。对基于 base 的档位,用一行 overlay 即可从 `$DSH_HOME/cordis.patch.yml` 或 `--patch` 文件重新启用。preset 文件不接受补丁(`packages/preset/agent-presets/README.md`),因此想要 `ralph` 的 Web 会话要以**新 id** 把 preset 复制到 `$DSH_HOME/.agent-presets` 并删掉 `disabled`;沿用随附 id 的副本会被随附 root 遮蔽,因为重复 id 由更靠前的 root 胜出(`packages/preset/agent-presets/src/index.ts`),而 `copy()` 也拒绝任何 root 已提供的 id。在 `ptc` 中,副本要同时删掉工具行和引擎行的 `disabled`,因为 `tool-ralph` 注入 `ctx.workflowEngine`。 +每个被禁用的行都在本地注释里带上恢复方法。对基于 base 的档位,用一行 overlay 即可从 `$DSH_HOME/cordis.patch.yml` 或 `--patch` 文件重新启用。随附 preset 是 Web bundle 补丁(`packages/bundle/web-app/cordis.patch.yml`)里的 `@deepseek-ai/dsh-agent-preset` 行,因此想要 `ralph` 的 Web 会话可以在 profile 补丁里覆盖该行的 `config.plugins`,或通过 Web 编辑器以**新 id** 保存一个 preset,声明会持久化到 `$DSH_HOME/profiles//cordis.patch.yml`;注册表拒绝重复的 preset id(`packages/preset/agent-preset-registry/src/index.ts`)。在 `ptc` 中,覆盖要同时删掉工具行和引擎行的 `disabled`,因为 `tool-ralph` 注入 `ctx.workflowEngine`。 `packages/bundle/web-app/cordis.patch.yml` 在自己的层里重述这条禁用声明。`scripts/verify-cordis-config.ts` 中的 `validatePresetPlaneSeparation` 在收集已声明的行 id 时不看 `disabled`,所以删掉这一行会让 `tool-ralph` 回到 Web 宿主平面,并与每个 preset 中的同名行冲突。 @@ -44,6 +44,6 @@ Status: implemented ## 验证 -`packages/preset/agent-presets/tests/shipped-root.spec.ts` 固定了四点:每个带 `tool-ralph` 的 preset 都禁用它;`minimal` 清单不含该行;`ptc` 禁用 `workflow-ptc`;`standard` 与 `cordis` 为各自的 `workflow` 工具保留引擎启用。`apps/cli/tests/web-agent-presets.e2e.ts` 与 `apps/web/tests/shipped-composition.e2e.ts` 固定默认与 PTC 的确切工具目录,因此某一行不再贡献会直接导致测试失败,而不是让列表悄悄变短。`scripts/verify-cordis-config.ts` 的平面隔离检查在禁用行存在的情况下继续通过。 +`apps/cli/tests/web-agent-presets.e2e.ts` 固定了四点:每个带 `tool-ralph` 的 preset 都禁用它;`minimal` 清单不含该行;`ptc` 禁用 `workflow-ptc`;`standard` 与 `cordis` 为各自的 `workflow` 工具保留引擎启用。`apps/cli/tests/web-agent-presets.e2e.ts` 与 `apps/web/tests/shipped-composition.e2e.ts` 固定默认与 PTC 的确切工具目录,因此某一行不再贡献会直接导致测试失败,而不是让列表悄悄变短。`scripts/verify-cordis-config.ts` 的平面隔离检查在禁用行存在的情况下继续通过。 多数受影响的录制会话旁挂文件由 `DSH_SNAPSHOT=refresh pnpm run test:snapshot` 重新生成,并由 `pnpm run test:snapshot` 回放整个语料。另有六个旁挂文件是人工整理,因为本机没有任何一次运行会产出它们。其中四个属于 `pwsh-tool-turn` 与 `persistent-pwsh-tool-turn`:本机缺少 `pwsh`,这两个场景在本地被跳过;它们删掉的文本与刷新在别处删掉的文本逐字节相同。剩下两个是 `snapshots/web/schedule-catalog`,没有任何在执行中的测试写入或读取它们:`schedule-after.e2e.ts` 只读该目录的 `catalog.expected.md` 与 `session.v3.jsonl`,`assertFixtureInventory` 也只检查那四个文件存在,因此它 `snapshot.yml` 里的 `header.pin: true` 并未被强制执行。`snapshots/session/ralph-loop` 在它自己的组合补丁下通过,这证明这次降级把该行移出了默认组合,却没有移除对该工具的覆盖。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 1fb5a7e132..e355fd822a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,15 +18,6 @@ "lib/*.js", "lib/types/*.d.ts" ], - "dsh": { - "configTrees": [ - { - "mount": "config/agent-presets", - "path": "../../packages/preset/agent-presets/presets", - "scanRoster": true - } - ] - }, "license": "MIT", "dependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -107,6 +98,7 @@ "@deepseek-ai/dsh-mcp-resources": "workspace:^", "@deepseek-ai/dsh-plugin-manager": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-agent-preset": "workspace:^", "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^" }, "devDependencies": { diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 0434078f16..7151747b4e 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,11 +1,12 @@ +import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' -import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' +import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { - boot, initProfile, createRuntimeResolution, loadOverlayPatches, @@ -19,7 +20,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings' -import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' +import { dump, load } from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { bundlePatchPaths, composeEntries } from '@deepseek-ai/dsh-app-boot' +const SETTINGS_NAMESPACE = 'agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -30,9 +34,12 @@ import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-token-meter' const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) -/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ +const { boot } = createRequire(import.meta.url)(join(REPO_ROOT, 'packages/boot/app-boot/lib/index.js')) as typeof import('@deepseek-ai/dsh-app-boot') +/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty profile. */ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') -const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +const WEB_BUNDLE = join(REPO_ROOT, 'packages/bundle/web-app') +const WEB_PATCHES = bundlePatchPaths(WEB_BUNDLE, (JSON.parse(readFileSync(join(WEB_BUNDLE, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string[] } } }).dsh.bundle) +const webPatches = (label: string): PatchOptions[] => WEB_PATCHES.flatMap(file => loadOverlayPatches(label, file)) const CODEX_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-codex') const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-claude-code') /** The installation anchor whose dependency surface the runtime resolution mirrors. */ @@ -62,8 +69,7 @@ async function bootWeb( // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it // reads the developer's own document — and since the default preset is a // setting, a stored `agent-presets.default` would decide this file's - // outcome. Point it at a temp file for the same reason the roster row - // below pins `includeUserRoot` off. + // outcome. Point it at a temporary file to isolate settings. { id: 'settings', config: { path: settingsFile, watch: false } }, // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this // file writes the developer's own `~/.dsh/storages/` — and then reads it @@ -114,12 +120,7 @@ async function bootWeb( { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }, { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' }, ] }, - // Pin the roster away from the developer's machine: `includeUserRoot` - // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. - // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. No `roots` entry: the plugin bundles the shipped - // presets itself and prepends their root. - { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { id: 'agent-preset-registry', config: { default: 'standard' } }, ...extra, ] const home = dirname(settingsFile) @@ -145,7 +146,7 @@ async function bootWeb( } let bundlePatches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', BASE_PATCH), - ...loadOverlayPatches('dsh-test', WEB_PATCH), + ...webPatches('dsh-test'), ] if (profileBundles !== undefined) { await writeFile(join(profileDir, 'package.json'), JSON.stringify({ @@ -156,6 +157,9 @@ async function bootWeb( profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false }) bundlePatches = profile.layers.flatMap(layer => layer.patches) } + const declarations = overrides.filter(patch => 'insert' in patch) + await writeFile(profile.patchPath, dump(declarations, { schema: entryListSchema })) + const launchOverrides = overrides.filter(patch => !('insert' in patch)) const resolution = await createRuntimeResolution({ installAnchor: INSTALL_ANCHOR, home, profile }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') @@ -163,7 +167,7 @@ async function bootWeb( bootCtx.provide('profileContext', { name: 'spec', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR, home, cwd: home, startedBundles: profileBundles ?? ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], - overlays: overrides, telemetryDisabledEnv: '1' }) + overlays: launchOverrides, telemetryDisabledEnv: '1' }) await bootCtx.plugin(PluginPackages, { resolution }) bootCtx.provide('connection', { fetch: { register: () => () => {} }, @@ -187,15 +191,13 @@ function toolParameterNames(ctx: Context, agent: Agent, toolName: string): strin } function enablePresetTool(composition: string, id: string): string { - const row = ` - id: ${id}\n` - const start = composition.indexOf(row) - if (start < 0) throw new Error(`missing preset row ${id}`) - const end = composition.indexOf('\n - id:', start + row.length) - const disabled = composition.indexOf(' disabled: true\n', start) - if (disabled < 0 || (end >= 0 && disabled > end)) { - throw new Error(`preset row ${id} is not disabled`) - } - return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) + const rows = load(composition, { schema: entryListSchema }) as import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] + const visit = (entries: typeof rows): boolean => entries.some((row) => { + if (row.id === id) { row.disabled = false; return true } + return row.group === true && visit(row.config as typeof rows) + }) + if (!visit(rows)) throw new Error(`missing preset row ${id}`) + return dump(rows, { schema: entryListSchema }) } let ctx: Context @@ -244,7 +246,7 @@ describe('the shipped Web composition', () => { const listed = await ctx.agentPresets.list() expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard']) - expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(listed.every(preset => !('path' in preset))).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -369,9 +371,15 @@ describe('the shipped Web composition', () => { expect(tools).not.toContain('str_replace_editor') expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() - // The persona is `standard`'s, pinned verbatim so the two composition - // files cannot drift apart: tool descriptions and the skill catalog - // carry every creation-mode instruction. + // The preset's own authoring skill registers into ITS layer of the host + // registry: the cordis agent's view carries it, the global view does not. + const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) + expect(scoped).toContain('editing-cordis-compositions') + expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions') + + // The persona is `standard`'s, pinned verbatim so the two declarations + // cannot drift apart: tool descriptions and the skill catalog carry + // every creation-mode instruction. const standard = await ctx.agents.create({ sessionId: SessionId('preset-cordis-standard-persona'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), @@ -385,12 +393,6 @@ describe('the shipped Web composition', () => { } finally { await standard.dispose() } - - // The preset's own authoring skill registers into ITS layer of the host - // registry: the cordis agent's view carries it, the global view does not. - const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) - expect(scoped).toContain('editing-cordis-compositions') - expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions') } finally { await handle.dispose() } @@ -442,11 +444,9 @@ describe('the shipped Web composition', () => { } }) - it('ships the composition-authoring skill inside the preset directory', async () => { - // The preset's skill root is derived from its own `baseUrl`, so the skill - // travels with the directory wherever the preset is installed. + it('ships the composition-authoring skill in the declaration package', async () => { const skill = join( - SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + REPO_ROOT, 'packages/preset/agent-preset', 'skills', 'editing-cordis-compositions', 'SKILL.md', ) expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) @@ -513,13 +513,12 @@ describe('the shipped Web composition', () => { } }) - it('never rewrites the preset file it composed from', async () => { + it('never rewrites the shipped profile patch when an Agent is disposed', async () => { // The Loader persists a tree whose plugin self-disposed, and tearing an // agent down disposes its whole subtree. Inherited, that rewrote the // shipped composition — truncating it to `[]` the first time a session // ended — so `PresetTree` refuses to write at all. - const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml') - const before = await readFile(path, 'utf8') + const before = await Promise.all(WEB_PATCHES.map(file => readFile(file, 'utf8'))) const handle = await ctx.agents.create({ sessionId: SessionId('preset-readonly'), @@ -533,7 +532,7 @@ describe('the shipped Web composition', () => { // so any wait past settlement fails; a longer one only slows the test. await new Promise(resolve => setTimeout(resolve, 50)) - expect(await readFile(path, 'utf8')).toBe(before) + expect(await Promise.all(WEB_PATCHES.map(file => readFile(file, 'utf8')))).toEqual(before) }) }) @@ -544,9 +543,10 @@ describe('product Bundle and user-preset intersection', () => { async function bootProducts(installed: readonly Product[]): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) - const userRoot = join(root, 'presets') + const definitions: import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] = [] const settingsFile = join(root, 'settings.yaml') - const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8') + const standardConfig = composeEntries([webPatches('test')]).find(row => row.id === 'preset-standard')!.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition + const standard = dump(standardConfig.plugins, { schema: entryListSchema }) await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard @@ -556,9 +556,7 @@ describe('product Bundle and user-preset intersection', () => { if (id === 'products-claude' || id === 'products-both') { composition = enablePresetTool(composition, 'tool-subagent-claude-code') } - const directory = join(userRoot, id) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'agent.cordis.yml'), composition) + definitions.push({ id: `preset-${id}`, name: '@deepseek-ai/dsh-agent-preset', config: { id, plugins: load(composition, { schema: entryListSchema }) } }) } const packageDir = (product: Product): string => ( product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR @@ -568,17 +566,7 @@ describe('product Bundle and user-preset intersection', () => { ? '@deepseek-ai/dsh-subagent-codex' : '@deepseek-ai/dsh-subagent-claude-code' ) - return await bootWeb(settingsFile, [ - { - id: 'agent-presets', - config: { - default: 'standard', - // The shipped root is the plugin's own, prepended before this. - roots: [{ path: userRoot, trust: 'user' }], - includeUserRoot: false, - }, - }, - ], installed.map(packageDir), [ + return await bootWeb(settingsFile, [{ insert: definitions } ], installed.map(packageDir), [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...installed.map(packageName), @@ -637,38 +625,9 @@ describe('product Bundle and user-preset intersection', () => { } } }, 120_000) - - it('applies a product-row edit only to later sessions on the preset', async () => { - const productCtx = await bootProducts(['codex']) - const preset = await productCtx.agentPresets.resolve('products-none') - const original = await readFile(preset.path, 'utf8') - const existing = await productCtx.agents.create({ - sessionId: SessionId('preset-product-generation-existing'), - setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), - }) - try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') - await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) - - const later = await productCtx.agents.create({ - sessionId: SessionId('preset-product-generation-later'), - setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), - }) - try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') - expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') - } finally { - await later.dispose() - } - } finally { - await existing.dispose() - await writeFile(preset.path, original) - await productCtx.fiber.dispose() - } - }, 120_000) }) -describe('a user preset copied from a shipped one', () => { +describe('a user preset declared from the shipped cordis rows', () => { /** The JSON text a `cordis_inspect_*` result renders. */ function resultText(result: { content: ReadonlyArray<{ type: string; text?: string }> }): string { return result.content.filter(part => part.type === 'text').map(part => part.text ?? '').join('') @@ -680,14 +639,13 @@ describe('a user preset copied from a shipped one', () => { // `tool-cordis` row only registers the tools. Before that split the copy // failed to mount: its row re-registered provider "Service". const root = await mkdtemp(join(tmpdir(), 'dsh-copied-preset-')) - const userRoot = join(root, 'presets') - await cp(join(SHIPPED_PRESET_ROOT, 'cordis'), join(userRoot, 'cordis-copy'), { recursive: true }) const settingsFile = join(root, 'settings.yaml') await writeFile(settingsFile, '{}\n') - const copyCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { default: 'standard', roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false }, - }]) + const cordis = composeEntries([webPatches('test')]).find(row => row.id === 'preset-cordis')!.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition + const copyCtx = await bootWeb(settingsFile, [{ insert: [{ + id: 'preset-cordis-copy', name: '@deepseek-ai/dsh-agent-preset', + config: { ...cordis, id: 'cordis-copy', name: 'Cordis copy' }, + }] }]) try { const shipped = await copyCtx.agents.create({ sessionId: SessionId(`preset-cordis-shipped-${randomUUID()}`), @@ -851,137 +809,6 @@ describe('a delegated child', () => { }) }) -describe('a launcher that configures no writable root', () => { - // The claim this default exists for, asserted through the real shipped - // bundles rather than a hand-built context: `apps/cli` patches in only the - // system root, and a person's own presets are found anyway because the - // roster derives `/.agent-presets` itself. `$DSH_HOME` is pointed - // at a temp home BEFORE boot — the derived root is resolved when the plugin - // is constructed, and an unpinned run would read the developer's own. - let derivedCtx: Context - let previousHome: string | undefined - - beforeAll(async () => { - const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-')) - previousHome = process.env.DSH_HOME - process.env.DSH_HOME = home - await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true }) - await writeFile( - join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'), - '- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n', - ) - const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') - await writeFile(settingsFile, '{}\n') - // No configured roots: the shipped one is the plugin's own, and the - // writable one is the roster's own default rather than this patch's job. - derivedCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { default: 'standard', includeUserRoot: true }, - }]) - }, 120_000) - - afterAll(async () => { - if (previousHome === undefined) delete process.env.DSH_HOME - else process.env.DSH_HOME = previousHome - await derivedCtx.fiber.dispose() - }) - - it('discovers and mounts a preset the person authored under the harness home', async () => { - const listed = await derivedCtx.agentPresets.list() - - const mine = listed.find(preset => preset.id === 'derived-mine') - expect(mine).toMatchObject({ trust: 'user' }) - // Omitted rather than undefined: a healthy row carries no `broken` key. - expect(mine?.broken).toBeUndefined() - expect(derivedCtx.agentPresets.authorable).toBe(true) - - const handle = await derivedCtx.agents.create({ - sessionId: SessionId('preset-derived-root'), - setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined), - }) - try { - expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write') - } finally { - await handle.dispose() - } - }) -}) - -describe('authoring a preset on the shipped composition', () => { - let authorCtx: Context - let userRoot: string - - beforeAll(async () => { - userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles') - const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml') - await writeFile(settingsFile, '{}\n') - authorCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { - default: 'standard', - // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. The shipped root is the - // plugin's own, prepended before this. - roots: [{ path: userRoot, trust: 'user' }], - includeUserRoot: false, - }, - }]) - }) - - it('refuses to copy over or delete a shipped preset', async () => { - await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/) - await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) - }) - - it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { - // The id becomes a directory name under the user root, so containment is - // checked on the id rather than on the joined path afterwards. - await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow() - }) - - it('copies a shipped preset a session then really composes from', async () => { - await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式') - - // Round-trips through the roster as a `user` row carrying the given name - // and the source's description, over the source's own composition text. - const preset = await authorCtx.agentPresets.resolve('my-agent') - const source = await authorCtx.agentPresets.resolve('minimal') - expect(preset.trust).toBe('user') - expect(preset.name).toBe('我的模式') - expect(preset.description).toBe(source.description) - expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal')) - // Owner-only, in an owner-only directory: a composition is executable - // configuration on a machine that may have other users. - expect((await stat(preset.path)).mode & 0o777).toBe(0o600) - const handle = await authorCtx.agents.create({ - sessionId: SessionId('preset-authored'), - setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), - }) - try { - // The same tools the shipped `minimal` composes, from a directory copied - // through the service into a root outside the installed harness. - expect(toolNames(authorCtx, handle.agent)).toEqual(['bash']) - } finally { - await handle.dispose() - } - }) - - it('deletes what it copied', async () => { - await authorCtx.agentPresets.copy('minimal', 'doomed') - - await authorCtx.agentPresets.remove('doomed') - - expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed') - }) -}) - -/** - * Which preset an unnamed session gets is a user setting layered over the - * composition's own default. The package suite proves the layering against a - * hand-built context; this proves it through the shipped `cordis.yml` — that - * the roster and the settings provider are actually wired to each other, and - * that the id the setting names is the one a session composes from. - */ describe('the default preset as a user setting', () => { it('composes an unnamed session from the stored default, not the composed one', async () => { expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) @@ -1031,63 +858,4 @@ describe('a session keeps the preset it was created with', () => { }) }) -describe('a composition that configures its own preset roots', () => { - let rootsCtx: Context - let teamRoot: string - - beforeAll(async () => { - const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-')) - const settingsFile = join(home, 'settings.yaml') - await writeFile(settingsFile, '{}\n') - // A workspace-shared root beside the deployment: one preset of its own, - // plus a directory that claims a shipped id. - teamRoot = join(home, 'team-presets') - const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8') - for (const id of ['team-spec', 'minimal']) { - await mkdir(join(teamRoot, id), { recursive: true }) - await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) - } - // The user layer of the reported regression: a profile's cordis.patch.yml - // configuring a shared preset root. The plugin must EXTEND it with its - // own shipped root, never lose it. - rootsCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: teamRoot, trust: 'user' }], - includeUserRoot: false, - }, - }]) - }, 120_000) - - afterAll(async () => { - await rootsCtx.fiber.dispose() - }) - - it('keeps configured roots alongside the always-prepended shipped root', async () => { - expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ - SHIPPED_PRESET_ROOT, - teamRoot, - ]) - - const listed = await rootsCtx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard', 'team-spec']) - expect(listed.every(preset => preset.broken === undefined)).toBe(true) - // The shipped root comes first: a configured directory claiming a shipped - // id is shadowed, never the other way around. - expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system') - expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user') - }) - - it('composes an agent from a configured-root preset', async () => { - const handle = await rootsCtx.agents.create({ - sessionId: SessionId('preset-team-spec'), - setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), - }) - try { - expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash']) - } finally { - await handle.dispose() - } - }) -}) +afterAll(async () => { await ctx?.fiber.dispose() }) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 5c9045e441..c10f7c869a 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -18,8 +18,7 @@ import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' -import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' +import { bundlePatchPaths, composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' /** * The effective disabled state of one row on one platform: a `!!js` expression @@ -102,13 +101,15 @@ describe('the shipped shell composition (real bundle layers)', () => { }) describe('shipped agent presets gate both shell tools by platform', () => { - const presetRoot = SHIPPED_PRESET_ROOT + const webBundle = fileURLToPath(new URL('../../../packages/bundle/web-app/', import.meta.url)) + const webManifest = JSON.parse(readFileSync(join(webBundle, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string[] } } } + const presetRows = composeEntries([bundlePatchPaths(webBundle, webManifest.dsh.bundle).flatMap(file => + yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema }) as import('@deepseek-ai/cordis-plugin-include').PatchOptions[])]) + + const definitions = presetRows.filter(row => row.name === '@deepseek-ai/dsh-agent-preset').map(row => row.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition) it.each(['standard', 'ptc', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { - const entries: unknown = yaml.load( - readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'), - { schema: entryListSchema }, - ) + const entries: unknown = definitions.find(row => row.id === preset)!.plugins if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`) for (const [id, win32] of [['tool-bash', true], ['tool-pwsh', false]] as const) { const row = entries.find((entry): entry is Record => ( @@ -124,10 +125,7 @@ describe('shipped agent presets gate both shell tools by platform', () => { }) it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => { - const entries: unknown = yaml.load( - readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), - { schema: entryListSchema }, - ) + const entries: unknown = definitions.find(row => row.id === 'minimal')!.plugins if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array') for (const id of ['tool-bash', 'tool-pwsh']) { expect(entries.some(entry => ( diff --git a/apps/desktop/scripts/test-host-updates.ts b/apps/desktop/scripts/test-host-updates.ts index c9b7a991e1..ba0a63b8f6 100644 --- a/apps/desktop/scripts/test-host-updates.ts +++ b/apps/desktop/scripts/test-host-updates.ts @@ -28,7 +28,7 @@ try { { id: 'session-title-llm', disabled: true }, { id: 'session-telemetry-otel', disabled: true }, { id: 'agent-instructions', disabled: true }, - { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { id: 'agent-preset-registry', config: { default: 'standard' } }, { insert: [{ id: 'update-qualification', name: new URL('../tests/fixtures/host-update-control.mjs', import.meta.url).href }] }, ])) const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index 245007a7ab..669d572b9d 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -52,7 +52,7 @@ try { { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, { id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true }, { id: 'session-telemetry-otel', disabled: true }, - { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { id: 'agent-preset-registry', config: { default: 'standard' } }, { insert: [{ id: 'update-control', name: new URL('../tests/fixtures/workspace-update-host.mjs', import.meta.url).href }] }, ])) const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 2572b1a5ff..51c250d8e3 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -1,315 +1,70 @@ -// Web e2e scenario: the agent-preset settings section as copy-only authoring. -// The browser never edits composition text — a shipped preset opens in a -// read-only viewer, the copy dialog collects an id and an optional display -// name, and the host copies the whole directory. The section's other job is -// getting the user TO the files: this lane pins `nativeOpen: false` (see the -// overlay), so the location affordance answers the preset directory as text — -// the deterministic branch a golden can hold on every platform. -// -// Zero model calls: no replay fixture mounts, so a stray stream fails loud. -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +/** The preset settings page only views and selects; authoring is guided to Creator mode. */ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import type { Locator } from 'playwright' -import { - captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, - webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' -const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url)) -const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') -const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') -const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') -const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') -/** The shipped roster, bundled inside the `dsh-agent-presets` package. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url)) -const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) -const MODE = webSnapshotMode() +const EXPECTED = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url)) +const mode = webSnapshotMode() -describe('web e2e: agent-preset authoring is a host-side copy', () => { +describe('web e2e: preset roster guidance', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType - let userRoot: string - - /** The settings dialog, opened on the Agent-presets section. */ - function settingsDialog(): Locator { - return page.getByRole('dialog', { name: '设置' }) - } - beforeAll(async () => { - userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) - scaffold = await launchWebScaffold({ - extraOverlayPath: OVERLAY, - profile: { packages: [] }, - agentPresets: { - // The shipped root is the plugin's own, prepended before this. - roots: [{ path: userRoot, trust: 'user' }], - default: 'standard', - }, - }) + scaffold = await launchWebScaffold({ profile: { packages: [] } }) browser = await chromium.launch() - // The scenario asserts the shipped Chinese copy, so the browser asks for it. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.getByRole('button', { name: '设置', exact: true }).click() + await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: 'Agent 预设' }).click() + await page.getByRole('heading', { name: 'Agent 预设' }).waitFor() }, 120_000) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) - afterAll(async () => { - await browser?.close() - await scaffold?.close() - await rm(userRoot, { recursive: true, force: true }) + it('shows the shipped roster with mode help and no editing actions', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-section')) + await expect.poll(() => page.locator('[data-agent-preset-id]').count()).toBe(4) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(EXPECTED, 'section.expected.md'), snapshot, mode) + expect(snapshot).toContain('如何创建或修改预设') + expect(snapshot).not.toContain('新建预设') + expect(snapshot).not.toContain('编辑插件') + expect(snapshot).not.toContain('打开目录') + expect(snapshot).not.toContain('删除') }) - it('offers the roster with copy as the only way to create', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section')) - await page.getByRole('button', { name: '设置', exact: true }).click() - const dialog = settingsDialog() - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) - // The intro copy also names 标准模式. Wait for the roster's own action so - // the snapshot cannot land between the section shell and its cards. - await dialog.getByRole('button', { name: '查看配置: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) - - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) - - await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) - const toggle = dialog.getByRole('switch', { name: '新任务可选择模式' }) - expect(await toggle.getAttribute('aria-checked')).toBe('true') - // The authoring entry is visible, and the shipped rows offer - // view/copy but never delete or a location — their - // install is overwritten by upgrades and is not the user's to manage. - expect(snapshot).toContain('让 Agent 帮我创建预设模式') - expect(snapshot).not.toContain('新建预设') - expect(snapshot).toContain('查看配置: 标准模式') - expect(snapshot).not.toContain('删除: 标准模式') - expect(snapshot).not.toContain('打开目录') - // The rest of this scenario exercises the existing default and Creator - // actions with the beta picker enabled by default. - }, 60_000) - - it('reads technical details and examples without changing the new-task default', async () => { - const settings = settingsDialog() + it('reads mode details and examples without changing the new-task default', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-guide')) + const settings = page.getByRole('dialog', { name: '设置' }) const trigger = settings.getByRole('button', { name: '模式说明: PTC 模式', exact: true }) await trigger.click() const guide = page.getByRole('dialog', { name: 'PTC 模式', exact: true }) await guide.getByRole('heading', { name: '怎样调用工具', exact: true }).waitFor() - const beforeSwitch = await guide.boundingBox() - expect(await guide.textContent()).toContain('run_code') await guide.getByRole('tab', { name: '如何使用', exact: true }).click() await guide.getByRole('heading', { name: '批量检查配置文件', exact: true }).waitFor() - expect(await guide.boundingBox()).toEqual(beforeSwitch) - await guide.getByRole('tab', { name: '如何使用', exact: true }).press('ArrowLeft') - expect(await guide.getByRole('tab', { name: '模式说明', exact: true }).getAttribute('aria-selected')).toBe('true') - await guide.getByRole('tab', { name: '模式说明', exact: true }).press('Escape') + await guide.getByRole('tab', { name: '如何使用', exact: true }).press('Escape') await guide.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => document.activeElement === element)).toBe(true) - expect(await settings.isVisible()).toBe(true) expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') - - await settings.getByRole('button', { name: '如何使用: 创造模式', exact: true }).click() - const creator = page.getByRole('dialog', { name: '创造模式', exact: true }) - await creator.getByRole('heading', { name: '添加一个界面', exact: true }).waitFor() - expect(await creator.getByRole('heading', { name: '添加一个工具', exact: true }).isVisible()).toBe(true) - const closePosition = await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox() - const examples = creator.getByRole('tabpanel', { name: '如何使用', exact: true }) - const scrolledTo = await examples.evaluate((element) => { - element.scrollTop = element.scrollHeight - return element.scrollTop - }) - expect(scrolledTo).toBeGreaterThan(0) - expect(await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()).toEqual(closePosition) - await creator.getByRole('tab', { name: '模式说明', exact: true }).click() - await creator.getByRole('tab', { name: '如何使用', exact: true }).click() - expect(await examples.evaluate(element => element.scrollTop)).toBe(scrolledTo) - await creator.getByRole('button', { name: '关闭', exact: true }).click() - await creator.waitFor({ state: 'detached' }) - }, 60_000) - - it('views a shipped composition read-only instead of editing it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '查看配置: 标准模式' }).click() - const viewer = page.getByRole('dialog', { name: '查看配置 · 标准模式' }) - await viewer.waitFor({ timeout: 10_000 }) - - // The real shipped composition, not a golden: the viewer shows whatever - // the deployment ships, and this lane only asserts it is shown read-only. - const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8') - expect(await viewer.locator('pre').textContent()).toBe(shipped) - expect(await viewer.getByRole('textbox').count()).toBe(0) - // The header X and the footer button share the 关闭 name; the footer one - // is last in the dialog. - await viewer.getByRole('button', { name: '关闭' }).last().click() - await viewer.waitFor({ state: 'detached', timeout: 10_000 }) - }, 60_000) - - it('copies 极简模式 whole under a new id and lands in its files', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() - const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) - await copyDialog.waitFor({ timeout: 10_000 }) - - const dialogSnapshot = await captureStableAria( - page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE) - // Two fields and nothing else: the id is the directory name the host - // needs up front; description and composition live in the files. - expect(dialogSnapshot).toContain('标识符') - expect(dialogSnapshot).not.toContain('描述') - - await copyDialog.getByPlaceholder('my-agent').fill('my-agent') - await copyDialog.getByPlaceholder('在模式列表中显示的名称,不填则使用标识符').fill('我的模式') - await copyDialog.getByRole('button', { name: '创建' }).click() - await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) - - // The new row lands in the custom group, and — with no desktop opener — - // its directory is revealed as text right away: landing in the files is - // the completion of a copy, not a follow-up. - await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 }) - await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 }) - // The copy dialog is detached, so the settings dialog is the only one - // left (it names itself via aria-labelledby, which a CSS attribute - // selector cannot address). - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { - replacements: [[userRoot, '{{presetRoot}}']], - }) - await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) - expect(snapshot).toContain('{{presetRoot}}/my-agent') - - // The host copied the whole directory and rewrote only the display - // metadata: the composition is byte-identical to the shipped source, the - // description rides along for the user to edit in place, and neither the - // source's name nor its roster order survives into the copy. - const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8') - expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) - const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') - expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。') - expect(metadata).not.toContain('order:') - }, 60_000) - - it('deletes the copy after confirmation and reclaims the roster', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete')) - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '删除: 我的模式' }).click() - const confirm = page.getByRole('dialog', { name: '删除该预设?' }) - await confirm.waitFor({ timeout: 10_000 }) - await confirm.getByRole('button', { name: '删除', exact: true }).click() - await confirm.waitFor({ state: 'detached', timeout: 10_000 }) - - await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) - expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) - // The custom group outlives its only member: the heading stays with the - // creator entry so the place to author a preset never disappears. - expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) - expect(await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).count()).toBe(1) - expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) - }, 60_000) - - it('marks damaged presets broken and clears a ghost through delete', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged')) - // The two hand-edit damage shapes: a composition that no longer parses, - // and a directory whose composition file was deleted outright. - await mkdir(join(userRoot, 'broken-yaml'), { recursive: true }) - await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n') - await mkdir(join(userRoot, 'ghost'), { recursive: true }) - await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n') - - // The section reads the roster when it mounts; hop away and back. - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '通用设置' }).click() - await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 }) - - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { - replacements: [[userRoot, '{{presetRoot}}']], - }) - await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) - // Both damage shapes surface as marked, unselectable, uncopyable cards - // that still carry their metadata and the discovery-reported reason. - expect(snapshot).toContain('加载失败: broken-yaml') - expect(snapshot).toContain('加载失败: 幽灵预设') - expect(snapshot).toContain('not valid YAML') - expect(snapshot).toContain('agent.cordis.yml is missing') - expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true) - expect(await dialog.getByRole('button', { name: '复制预设: 幽灵预设' }).isDisabled()).toBe(true) - // A broken card offers no "set default" affordance at all — the aria name - // IS the broken marking, so the picking name must not exist. - expect(await dialog.getByRole('button', { name: '设为新任务默认: broken-yaml' }).count()).toBe(0) - - // The ghost's way out is the card's own delete — and the id it blocked - // is claimable again immediately afterwards. - await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click() - const confirm = page.getByRole('dialog', { name: '删除该预设?' }) - await confirm.waitFor({ timeout: 10_000 }) - await confirm.getByRole('button', { name: '删除', exact: true }).click() - await confirm.waitFor({ state: 'detached', timeout: 10_000 }) - await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0) - expect(existsSync(join(userRoot, 'ghost'))).toBe(false) - - await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() - const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) - await copyDialog.waitFor({ timeout: 10_000 }) - await copyDialog.getByPlaceholder('my-agent').fill('ghost') - await copyDialog.getByRole('button', { name: '创建' }).click() - await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) - await dialog.getByRole('button', { name: '设为新任务默认: ghost' }).waitFor({ timeout: 10_000 }) - - // Leave the roster as the earlier tests shaped it. - await dialog.getByRole('button', { name: '删除: ghost' }).click() - const cleanup = page.getByRole('dialog', { name: '删除该预设?' }) - await cleanup.waitFor({ timeout: 10_000 }) - await cleanup.getByRole('button', { name: '删除', exact: true }).click() - await cleanup.waitFor({ state: 'detached', timeout: 10_000 }) - await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true }) - }, 60_000) - - it('starts a creator-mode session from the section', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator')) - // Without a workspace the flow only stages (there is no session to land - // in until one is connected); connect first so the gesture carries all - // the way to a composed host session. - await settingsDialog().getByRole('button', { name: '关闭' }).last().click() - await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) - await page.getByRole('button', { name: '设置', exact: true }).click() - const dialog = settingsDialog() - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).click() - - // Leaving settings is part of the gesture: the flow lands on the - // new-session screen with the self-referential preset staged, and the - // blank session the flow produces composes from it on the host. - await dialog.waitFor({ state: 'detached', timeout: 10_000 }) - await expect.poll(async () => { - const response = await scaffold.hostFetch('/api/session/list', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list', - payload: { args: { _request: {} } }, - }), - }) - const body = await response.json() as { - result: { value?: { items: unknown[] } } - } - return JSON.stringify(body.result.value?.items ?? body.result) - }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') - }, 60_000) - - it('drove every surface without a page error or a stream warning', () => { - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) }) + + it('guides authoring to Creator mode instead of editing here', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-authoring-help')) + await page.getByRole('button', { name: '如何创建或修改预设', exact: true }).click() + const help = page.getByRole('dialog', { name: '创建和修改预设', exact: true }) + await help.waitFor() + const snapshot = await captureStableAria(page, '[role="dialog"][aria-label="创建和修改预设"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(EXPECTED, 'authoring-help.expected.md'), snapshot, mode) + expect(snapshot).toContain('让 Agent 帮我创建预设模式') + await help.getByRole('button', { name: '关闭', exact: true }).last().click() + await help.waitFor({ state: 'detached' }) + }) + + it('runs without page errors or model calls', () => { expect(tripwire.pageErrors).toEqual([]) }) }) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml deleted file mode 100644 index 3791809a7e..0000000000 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ /dev/null @@ -1,13 +0,0 @@ -# The authoring lane drives the location affordance. A real desktop open -# would pop a file manager on the machine running the tests and the -# capability itself is platform-detected (macOS yes, headless Linux CI no), -# so both native-open owners are pinned headless: `hasDocument` is false everywhere and -# `openDocument` answers the directory as text — the same branch on every -# host, and the one whose rendering a golden can hold. A patch replaces the -# row's complete config, so the shipped routing defaults ride along. -- id: session-controller - config: - nativeOpen: false -- id: settings-controller - config: - nativeOpen: false diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index c4198ae414..15d93537f5 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -8,7 +8,7 @@ // because the host answers `agent-preset-locked` to anything else. // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -42,22 +42,15 @@ const SKILL_NAME = 'preset-catalog-demo' /** The preset whose rows resolve and then refuse to start. */ const REFUSING_ID = 'zz-refusing' -/** - * Seed a preset discovery reports healthy and the mount refuses. - * - * Every row resolves — the module is right there beside the composition — so - * health has nothing to report and the chip offers the preset like any other. - * Only starting it finds out, which is the case the chip's banner exists for. - * @param root - the lane's writable preset root. +/** Write the fixture module whose declaration fails during eager activation. + * @param root Temporary directory holding the fixture module. */ async function seedRefusingPreset(root: string): Promise { const directory = join(root, REFUSING_ID) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'refuses.mjs'), 'export const name = \'refuses\'\nexport function apply() { throw new Error(\'this row refuses to start\') }\n') - await writeFile(join(directory, 'agent.cordis.yml'), '- id: refuses\n name: ./refuses.mjs\n') - await writeFile(join(directory, 'preset.yml'), - 'name: Refusing mode\ndescription: Resolves, then refuses to start.\n') + } /** @@ -228,15 +221,14 @@ describe('web e2e: agent-preset selection', () => { let browser: Browser let page: Page let tripwire: ReturnType - let presetRoot: string + let fixtureRoot: string beforeAll(async () => { - // The shipped presets, plus one lane-owned preset that mounts and refuses: - // the chip's own failure path needs a preset the roster offers. - presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-'))) - await seedRefusingPreset(presetRoot) + // The failed declaration remains in the registry, outside the selectable options. + fixtureRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-'))) + await seedRefusingPreset(fixtureRoot) scaffold = await launchWebScaffold({ - agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' }, + agentPresets: { default: 'standard', definitions: [{ id: REFUSING_ID, name: 'Refusing mode', description: 'Refuses to start.', plugins: [{ name: pathToFileURL(join(fixtureRoot, REFUSING_ID, 'refuses.mjs')).href }] }] }, }) // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the @@ -254,7 +246,7 @@ describe('web e2e: agent-preset selection', () => { afterAll(async () => { await browser?.close() await scaffold?.close() - await rm(presetRoot, { recursive: true, force: true }) + await rm(fixtureRoot, { recursive: true, force: true }) }) it('starts with mode selection shown on the Standard default', async () => { @@ -303,20 +295,13 @@ describe('web e2e: agent-preset selection', () => { expect(roster.presets.find(preset => preset.isDefault)?.id).toBe('standard') }) - it('says why a switch was refused instead of letting the chip revert in silence', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-refused')) + it('omits eagerly failed presets from selection and retains their diagnostics', async () => { await page.getByRole('button', { name: 'Minimal mode' }).click() - await page.getByRole('menuitem', { name: /Refusing mode/ }).click() - - // Health cleared every row, so nothing on the settings page says this - // preset is unusable — the banner is where the host's reason lands, and - // without it the chip just snaps back to the preset it already ran. - const banner = page.getByRole('alert').filter({ hasText: 'Refusing mode' }) - await banner.waitFor({ timeout: 15_000 }) - expect(await banner.textContent()).toContain('this row refuses to start') - await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') - await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 }) - }, 60_000) + await page.getByRole('menu').waitFor() + expect(await page.getByRole('menuitem', { name: /Refusing mode/ }).count()).toBe(0) + await page.keyboard.press('Escape') + expect((await scaffold.ctx.agentPresets.resolve(REFUSING_ID)).broken).toContain('this row refuses to start') + }) it('re-reads the slash catalog through the composition the switch installed', async () => { // Continues 'applies the staged pick': the chip has already applied `minimal` to diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 97202b950e..b7ab90a8e2 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -52,21 +52,16 @@ interface ComposedEntry { } interface BootComposition { + bundlePatchPaths(packageDir: string, bundle: { patch: string | string[] }): string[] loadOverlayPatches(binName: string, file: string): unknown[] composeEntries(layers: readonly unknown[][]): ComposedEntry[] } const REPO_ROOT = process.cwd() -const BUNDLE_LAYERS = [ - { - manifest: join(REPO_ROOT, 'packages/bundle/base/package.json'), - patch: join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml'), - }, - { - manifest: join(REPO_ROOT, 'packages/bundle/web-app/package.json'), - patch: join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml'), - }, -] as const +const BUNDLE_LAYERS = ['packages/bundle/base', 'packages/bundle/web-app'].map(dir => ({ + dir: join(REPO_ROOT, dir), + manifest: join(REPO_ROOT, dir, 'package.json'), +})) const bundleResolvers = BUNDLE_LAYERS.map(layer => createRequire(layer.manifest)) const webBundleResolver = bundleResolvers[1] if (webBundleResolver === undefined) throw new Error('assembled boot: web bundle resolver missing') @@ -97,8 +92,10 @@ const comboReference = (ids: readonly string[], rev: string): string => /** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */ function loadAssembledPlugins(): readonly AssembledPlugin[] { - const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer => - appBoot.loadOverlayPatches('assembled boot', layer.patch))) + const entries = appBoot.composeEntries(BUNDLE_LAYERS.map((layer) => { + const declared = (JSON.parse(readFileSync(layer.manifest, 'utf8')) as { dsh: { bundle: { patch: string | string[] } } }).dsh.bundle + return appBoot.bundlePatchPaths(layer.dir, declared).flatMap(patch => appBoot.loadOverlayPatches('assembled boot', patch)) + })) const plugins = new Map() for (const entry of entries) { if (entry.disabled === true || typeof entry.name !== 'string') continue diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 8d7502264b..b67b8c9837 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -153,9 +153,8 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'session/modelCatalog': structuredClone(fixture.modelCatalog), 'agentPresets/list': structuredClone(fixture.agentPresets), 'commands/list': structuredClone(fixture.commands), - 'settings/canOpenAgentPresetDirectory': ok(true), 'settings/openSettingsDocument': ok({ opened: true }), - 'settings/openAgentPresetDirectory': ok({ opened: true }), + 'subagents/list': ok({ entries: [], parentAvailable: true }), 'terminal/list': ok([]), 'skills/list': ok({ skills: [] }), 'session/canOpenWorkspacePath': ok(true), diff --git a/apps/web/tests/expected/agent-preset-authoring/authoring-help.expected.md b/apps/web/tests/expected/agent-preset-authoring/authoring-help.expected.md new file mode 100644 index 0000000000..f264bf7a11 --- /dev/null +++ b/apps/web/tests/expected/agent-preset-authoring/authoring-help.expected.md @@ -0,0 +1,6 @@ +- dialog "创建和修改预设": + - heading "创建和修改预设" [level=2] + - button "关闭" + - paragraph: 预设由已安装的 bundle 声明,本页只负责选择。要创建或修改预设,请用「创造模式」开始一个任务:Agent 会编写一个声明或覆盖该预设的 bundle,并安装到当前 profile。 + - button "关闭" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md deleted file mode 100644 index f3d67b4e69..0000000000 --- a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md +++ /dev/null @@ -1,13 +0,0 @@ -- dialog "复制预设 · 复制自 极简模式": - - heading "复制预设 · 复制自 极简模式" [level=2] - - button "关闭" - - paragraph: 复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。 - - text: 标识符 - - textbox "标识符": - - /placeholder: my-agent - - text: 名称 - - textbox "名称": - - /placeholder: 在模式列表中显示的名称,不填则使用标识符 - - alert: 请填写标识符。 - - button "取消" - - button "创建" [disabled] diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md deleted file mode 100644 index 20455df885..0000000000 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ /dev/null @@ -1,66 +0,0 @@ -- dialog "设置": - - navigation: - - text: 设置 - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "Agent 预设" [level=2] - - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - - text: 新任务可选择模式 Beta - - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 - - switch "新任务可选择模式" [checked] - - heading "内置" [level=3] - - list: - - listitem: - - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 新任务默认 - - code: standard - - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - - 'button "模式说明: 标准模式"': 模式说明 - - 'button "如何使用: 标准模式"': 如何使用 - - 'button "查看配置: 标准模式"': 查看配置 - - 'button "复制预设: 标准模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: PTC 模式"': - - text: PTC 模式 内置 - - code: ptc - - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - - 'button "模式说明: PTC 模式"': 模式说明 - - 'button "如何使用: PTC 模式"': 如何使用 - - 'button "查看配置: PTC 模式"': 查看配置 - - 'button "复制预设: PTC 模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 极简模式"': - - text: 极简模式 内置 - - code: minimal - - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - - 'button "模式说明: 极简模式"': 模式说明 - - 'button "如何使用: 极简模式"': 如何使用 - - 'button "查看配置: 极简模式"': 查看配置 - - 'button "复制预设: 极简模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 创造模式"': - - text: 创造模式 内置 - - code: cordis - - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - - 'button "模式说明: 创造模式"': 模式说明 - - 'button "如何使用: 创造模式"': 如何使用 - - 'button "查看配置: 创造模式"': 查看配置 - - 'button "复制预设: 创造模式"': 复制预设 - - heading "自定义" [level=3] - - list: - - listitem: - - 'button "设为新任务默认: 我的模式"': - - text: 我的模式 自定义 - - code: my-agent - - text: 仅提供持久 shell 的单工具编码 Agent。 - - 'button "查看路径: 我的模式"': 查看路径 - - 'button "复制预设: 我的模式"': 复制预设 - - 'button "删除: 我的模式"': 删除 - - paragraph: - - text: 预设文件: - - code: {{presetRoot}}/my-agent - - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md deleted file mode 100644 index a7434c0336..0000000000 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ /dev/null @@ -1,73 +0,0 @@ -- dialog "设置": - - navigation: - - text: 设置 - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "Agent 预设" [level=2] - - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - - text: 新任务可选择模式 Beta - - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 - - switch "新任务可选择模式" [checked] - - heading "内置" [level=3] - - list: - - listitem: - - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 新任务默认 - - code: standard - - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - - 'button "模式说明: 标准模式"': 模式说明 - - 'button "如何使用: 标准模式"': 如何使用 - - 'button "查看配置: 标准模式"': 查看配置 - - 'button "复制预设: 标准模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: PTC 模式"': - - text: PTC 模式 内置 - - code: ptc - - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - - 'button "模式说明: PTC 模式"': 模式说明 - - 'button "如何使用: PTC 模式"': 如何使用 - - 'button "查看配置: PTC 模式"': 查看配置 - - 'button "复制预设: PTC 模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 极简模式"': - - text: 极简模式 内置 - - code: minimal - - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - - 'button "模式说明: 极简模式"': 模式说明 - - 'button "如何使用: 极简模式"': 如何使用 - - 'button "查看配置: 极简模式"': 查看配置 - - 'button "复制预设: 极简模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 创造模式"': - - text: 创造模式 内置 - - code: cordis - - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - - 'button "模式说明: 创造模式"': 模式说明 - - 'button "如何使用: 创造模式"': 如何使用 - - 'button "查看配置: 创造模式"': 查看配置 - - 'button "复制预设: 创造模式"': 复制预设 - - heading "自定义" [level=3] - - list: - - listitem: - - 'button "加载失败: broken-yaml" [disabled]': - - text: broken-yaml 加载失败 自定义 - - code: broken-yaml - - text: 暂无描述。 - - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - - 'button "查看路径: broken-yaml"': 查看路径 - - 'button "复制预设: broken-yaml" [disabled]': 预设加载失败,不能复制 - - 'button "删除: broken-yaml"': 删除 - - listitem: - - 'button "加载失败: 幽灵预设" [disabled]': - - text: 幽灵预设 加载失败 自定义 - - code: ghost - - text: composition 已被手动删除。 - - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - - 'button "查看路径: 幽灵预设"': 查看路径 - - 'button "复制预设: 幽灵预设" [disabled]': 预设加载失败,不能复制 - - 'button "删除: 幽灵预设"': 删除 - - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 44b226efc0..4eb1c8791d 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -8,6 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] + - button "如何创建或修改预设" - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - text: 新任务可选择模式 Beta - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 @@ -21,8 +22,6 @@ - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - 'button "模式说明: 标准模式"': 模式说明 - 'button "如何使用: 标准模式"': 如何使用 - - 'button "查看配置: 标准模式"': 查看配置 - - 'button "复制预设: 标准模式"': 复制预设 - listitem: - 'button "设为新任务默认: PTC 模式"': - text: PTC 模式 内置 @@ -30,8 +29,6 @@ - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - 'button "模式说明: PTC 模式"': 模式说明 - 'button "如何使用: PTC 模式"': 如何使用 - - 'button "查看配置: PTC 模式"': 查看配置 - - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 @@ -39,8 +36,6 @@ - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 - - 'button "查看配置: 极简模式"': 查看配置 - - 'button "复制预设: 极简模式"': 复制预设 - listitem: - 'button "设为新任务默认: 创造模式"': - text: 创造模式 内置 @@ -48,7 +43,3 @@ - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - 'button "模式说明: 创造模式"': 模式说明 - 'button "如何使用: 创造模式"': 如何使用 - - 'button "查看配置: 创造模式"': 查看配置 - - 'button "复制预设: 创造模式"': 复制预设 - - heading "自定义" [level=3] - - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 76b76a3463..2f715b99b1 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -3,4 +3,3 @@ - menuitem "PTC mode Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results." - menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance." - menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode." - - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/expected/new-session-refused/notice.expected.md b/apps/web/tests/expected/new-session-refused/notice.expected.md index 52e7072f0f..ee4467be62 100644 --- a/apps/web/tests/expected/new-session-refused/notice.expected.md +++ b/apps/web/tests/expected/new-session-refused/notice.expected.md @@ -1 +1 @@ -- alert: "新建会话失败:agent-preset/invalid: agent-presets: preset \"renamed-plugin\" failed to mount: row \"ghost\" names a plugin that cannot be resolved: @deepseek-ai/dsh-no-such-plugin" +- alert: "新建会话失败:agent-preset/invalid: ghost (@deepseek-ai/dsh-no-such-plugin): never started" diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json index 9d0d916a99..e375c6deb5 100644 --- a/apps/web/tests/fixtures/assembled-remote.fixture.json +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -182,7 +182,6 @@ "isDefault": false } ], - "authorable": true, "modeSelectionEnabled": true } }, diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 1c41897422..f7d72cddc5 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -8,7 +8,7 @@ import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-system-prompt' import { assertFixtureInventory, diff --git a/apps/web/tests/new-session-refused.e2e.ts b/apps/web/tests/new-session-refused.e2e.ts index 80dcb8e990..9f3a50a41a 100644 --- a/apps/web/tests/new-session-refused.e2e.ts +++ b/apps/web/tests/new-session-refused.e2e.ts @@ -1,7 +1,4 @@ /** An explicit New Session the Host refuses reports through the Workspace notice in the shipped Web composition. */ -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import { describe, expect, it, onTestFinished } from 'vitest' @@ -13,28 +10,19 @@ const MODE = webSnapshotMode() /** The default preset of this lane: its one row names a package the Host cannot resolve, as a preset copied before a plugin rename does. */ const PRESET_ID = 'renamed-plugin' -/** - * Seed a user preset root holding one preset the roster reads as broken. - * - * Discovery reports the unresolvable row before any mount, so the refusal - * carries no temp path and the notice text is stable across runs. - * @param root - the lane-owned preset root. - */ -async function seedBrokenPreset(root: string): Promise { - const directory = join(root, PRESET_ID) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'agent.cordis.yml'), '- id: ghost\n name: \'@deepseek-ai/dsh-no-such-plugin\'\n') - await writeFile(join(directory, 'preset.yml'), 'name: Renamed plugin\ndescription: Names a plugin that no longer resolves.\n') +/** The default preset of this lane as a declaration: its one row names a package the Host cannot resolve. */ +const BROKEN_PRESET = { + id: PRESET_ID, + name: 'Renamed plugin', + description: 'Names a plugin that no longer resolves.', + plugins: [{ id: 'ghost', name: '@deepseek-ai/dsh-no-such-plugin' }], } describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => { it('shows the Host refusal as a notice and leaves the Workspace without a Session', async () => { - const presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-broken-preset-'))) - onTestFinished(() => rm(presetRoot, { recursive: true, force: true })) - await seedBrokenPreset(presetRoot) const scaffold = await launchWebScaffold({ firstUse: true, - agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: PRESET_ID }, + agentPresets: { default: PRESET_ID, definitions: [BROKEN_PRESET] }, }) onTestFinished(() => scaffold.close()) const browser = await chromium.launch() diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 36d237857a..1c525a93f0 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -56,7 +56,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), - agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, + agentPresets: { default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) // File associations belong to the desktop rather than the recorded Session. diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 37fdd06073..3ca55f2f85 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { generationLogPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { scanZstdFrames } from '../../../packages/session/session-persistence-jsonl/src/zstd.ts' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-api-session-controller' import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' diff --git a/apps/web/tests/ptc-escalation.e2e.ts b/apps/web/tests/ptc-escalation.e2e.ts index 9f71da0890..3f3a74a65a 100644 --- a/apps/web/tests/ptc-escalation.e2e.ts +++ b/apps/web/tests/ptc-escalation.e2e.ts @@ -32,7 +32,7 @@ describe('web e2e: PTC program sandbox escalation', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - agentPresets: { roots: [], default: 'ptc' }, + agentPresets: { default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 54bcc461c9..abf392c727 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -32,7 +32,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - agentPresets: { roots: [], default: 'ptc' }, + agentPresets: { default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index 585d10ab98..df34f424fe 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-skill' import { SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' async function writeSkill(root: string, name: string): Promise { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 90f6387c7d..05fc0f28f4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -195,7 +195,8 @@ export function recordedSessionFixturePath(path: string, version: number): strin /** The shipped composition under test: the dsh-base and dsh-web-app bundle patches over the empty profile root. */ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') -const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +const WEB_BUNDLE_DIR = join(REPO_ROOT, 'packages/bundle/web-app') +const WEB_BUNDLE_PATCH = (JSON.parse(readFileSync(join(WEB_BUNDLE_DIR, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string[] } } }).dsh.bundle /** The installation anchor whose dependency surface the runtime resolution mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') @@ -405,18 +406,10 @@ export interface LaunchOptions { /** Credential reference resolved by the shipped search provider. */ apiKeyEnv: string } - /** - * Replace the roster row the scaffold pins by default (no configured roots, - * default `standard` — the plugin's own shipped presets). Supply this only - * to change WHICH presets a scenario sees beyond the shipped set — a - * writable user root, a different default. The patch lands after the - * default, so it wins. - */ + /** Preset selection default and additional declarative definitions for this scenario. */ agentPresets?: { - /** Roots to discover after the plugin's shipped root, in precedence order. */ - roots: { path: string; trust: 'system' | 'user' }[] - /** The preset a session that names none is composed from. */ default: string + definitions?: import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition[] } /** * Patch the telemetry exporter URL while preserving the shipped enabled @@ -466,6 +459,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise loadOverlayPatches('web e2e scaffold', file)) const extraOverlayPatches = options.extraOverlayPath === undefined ? [] : (typeof options.extraOverlayPath === 'string' ? [options.extraOverlayPath] : options.extraOverlayPath) @@ -573,17 +567,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) }, + ], ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }], ...options.deepSeekSearch === undefined ? [] @@ -796,8 +779,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog')) const base = composeEntries([ loadOverlayPatches('Schedule catalog base roster', BASE_PATCH), - loadOverlayPatches('Schedule catalog base roster', WEB_PATCH), + ...WEB_PATCHES.map(file => loadOverlayPatches('Schedule catalog base roster', file)), ]) const scheduled = composeEntries([ loadOverlayPatches('Schedule catalog overlay roster', BASE_PATCH), - loadOverlayPatches('Schedule catalog overlay roster', WEB_PATCH), + ...WEB_PATCHES.map(file => loadOverlayPatches('Schedule catalog overlay roster', file)), loadOverlayPatches('Schedule catalog overlay roster', OVERLAY), ]) expect(base.find(entry => entry.id === 'ui-schedule')).toMatchObject({ diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 969ade598a..5c82ae3cc3 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -295,11 +295,9 @@ describe('web e2e: seeded history renders through cold resume', () => { // The seed carries a session/title event: the title unit is host-plane, so // it folds the detached log and serves the value with nothing composed. expect(typeof projections.values.title).toBe('string') - // `todos` is absent because its unit belongs to the agent preset and this - // directly seeded session never composed that preset. History computes - // the baseline through the standard projection registry without mounting - // an Agent composition as a read side effect. - expect(projections.values).not.toHaveProperty('todos') + // Eager preset activation registers the todo projection before any Agent + // opens this log. A log without todo events projects its empty value. + expect(projections.values.todos).toBeNull() // The session-stats unit is a shipped web-app bundle row: whole-log // turn/step counts ride the same tail block (the stats strip's source). const sessionStats = projections.values.sessionStats as { turns: number; steps: number } | undefined diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 0d21a890ac..65be39cec9 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -717,7 +717,7 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) // The plugin list resolves shipped preset names through the en - // dictionaries instead of echoing the preset files' Chinese metadata. + // dictionaries instead of echoing the preset declarations' Chinese metadata. await dialog.getByRole('button', { name: 'Built-in plugins', exact: true }).click() const presetSwitcher = dialog.getByRole('button', { name: 'Choose the agent preset to inspect' }) await presetSwitcher.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index a6656d2ff5..d1e6ecd83a 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -19,7 +19,7 @@ import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-permission-presets' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-terminal' diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 1b6a9e7f10..9897eeb579 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -66,7 +66,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { const output = call.locator('pre') await output.waitFor() expect(await output.textContent()).toContain('') - expect(await output.textContent()).toContain('Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI') + expect(await output.textContent()).toContain('Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches.') expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/benchmarks/package.json b/benchmarks/package.json index ad685bcb27..87f0000190 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -14,7 +14,7 @@ "@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-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index 4ac180eab7..b1e2bfec1d 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -5,7 +5,7 @@ import { scheduler } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { interruptedTurnClosers, SessionId, diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index a1e749da0f..73405ef455 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: bbd44bce011404ee476131b9f498618a8ef5c708 -capability-seams.zh.md: 4e9f826bc27420ba372ac6feb688cc23bc58e141 +capability-seams.md: 66425afe98bcbefe69912bf130cb72bba9435ec0 +capability-seams.zh.md: faa31be64081a80d58a0f302f91c737c34ed3f38 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index bbd44bce01..66425afe98 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -143,7 +143,7 @@ flowchart LR svc_userQuestions["ctx.userQuestions
Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode
Plan collaboration state"] - pkg_agent_presets["agent-presets"] + pkg_agent_preset_registry["agent-preset-registry"] svc_agentPresets["ctx.agentPresets
Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] @@ -264,7 +264,7 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop - pkg_agent_presets --> svc_agentPresets + pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController @@ -600,7 +600,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`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 PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends 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 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.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | Eagerly mounts YAML-declared preset revisions, binds Agents and cold readers to scoped contributions, and retains retired revisions until their last user releases them. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 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/capability-seams.zh.md b/docs/capability-seams.zh.md index 4e9f826bc2..faa31be640 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -145,7 +145,7 @@ flowchart LR svc_userQuestions["ctx.userQuestions
Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode
Plan collaboration state"] - pkg_agent_presets["agent-presets"] + pkg_agent_preset_registry["agent-preset-registry"] svc_agentPresets["ctx.agentPresets
Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] @@ -266,7 +266,7 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop - pkg_agent_presets --> svc_agentPresets + pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController @@ -602,7 +602,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | -| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | +| `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | 立即挂载 YAML 声明的 preset 版本,把 Agent 和冷读取绑定到作用域内的贡献,并保留已替换的版本,直到最后一个使用者释放它。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7c73a927de..1bb4e69c24 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: d3d1b4fe47bf6dc745dc9a00ac6ef317ab0d3ca8 -config-catalog.zh.md: 3f204af30d5e973c03cd24c6c2873bb7756d334e +config-catalog.md: 4ce0bc0a4f660fe3f27091012072c5799231f12b +config-catalog.zh.md: b8077cd13fff1f7c3b0a89f9f9c335a8716c18ac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d3d1b4fe47..4ce0bc0a4f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -113,51 +113,36 @@ Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/cor Source: [`packages/core/agent-loop/src/index.ts:319`](../packages/core/agent-loop/src/index.ts) - + -## `@deepseek-ai/dsh-agent-presets` +## `@deepseek-ai/dsh-agent-preset` + +Requires: `agentPresets` + +```ts config-catalog +/** Definition submitted to the preset registry. */ +export type Config = PresetDefinition +``` + +Depends on: [`PresetDefinition`](../packages/preset/agent-preset-registry/src/index.ts) + +Source: [`packages/preset/agent-preset/src/index.ts:9`](../packages/preset/agent-preset/src/index.ts) + + + +## `@deepseek-ai/dsh-agent-preset-registry` Requires: `loader` · `sessionProjections` ```ts config-catalog -/** Plugin config: which preset is the default, and where presets live. */ +/** Registry selection policy. */ export interface Config { - /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + /** Deployment default when the caller omits a preset. */ default: string - /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ - roots: PresetRoot[] - /** - * Prepend this package's bundled shipped presets as a `system` root, before - * every configured root, so the shipped set always mounts and wins a - * duplicate id. The default survives a whole-`config` patch replacement; - * only an explicit `false` — a deployment supplying purely its own presets, - * or an embedder using the roster as bare machinery — drops the set. - */ - includeShippedRoot: boolean - /** - * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every - * configured root. False mounts a roster without the derived writable root. - */ - includeUserRoot: boolean } - -/** One directory scanned for preset subdirectories. */ -export interface PresetRoot { - /** Directory holding one subdirectory per preset; a leading `~` expands. */ - path: string - /** Trust recorded on every preset discovered under this root. */ - trust: PresetTrust -} - -/** - * 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' ``` -Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) +Source: [`packages/preset/agent-preset-registry/src/preset.ts:12`](../packages/preset/agent-preset-registry/src/preset.ts) @@ -240,14 +225,14 @@ Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/sess ## `@deepseek-ai/dsh-api-settings-controller` ```ts config-catalog -/** Native document-opening policy. */ -export interface Config { - /** Override platform desktop-opener detection. */ - readonly nativeOpen?: boolean +/** Host integrations replaceable by direct unit tests. */ +export interface SettingsControllerInternals { + /** Host text-editor integration used to open the settings document. */ + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise } ``` -Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) +Source: [`packages/api/settings-controller/src/index.ts:35`](../packages/api/settings-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3f204af30d..b8077cd13f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -115,51 +115,36 @@ Depends on: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/ 来源:[`packages/core/agent-loop/src/index.ts:319`](../packages/core/agent-loop/src/index.ts) - + -## `@deepseek-ai/dsh-agent-presets` +## `@deepseek-ai/dsh-agent-preset` -需要:`loader` · `sessionProjections` +需要: `agentPresets` ```ts config-catalog -/** Plugin config: which preset is the default, and where presets live. */ -export interface Config { - /** 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[] - /** - * Prepend this package's bundled shipped presets as a `system` root, before - * every configured root, so the shipped set always mounts and wins a - * duplicate id. The default survives a whole-`config` patch replacement; - * only an explicit `false` — a deployment supplying purely its own presets, - * or an embedder using the roster as bare machinery — drops the set. - */ - includeShippedRoot: boolean - /** - * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every - * configured root. False mounts a roster without the derived writable root. - */ - includeUserRoot: boolean -} - -/** One directory scanned for preset subdirectories. */ -export interface PresetRoot { - /** Directory holding one subdirectory per preset; a leading `~` expands. */ - path: string - /** Trust recorded on every preset discovered under this root. */ - trust: PresetTrust -} - -/** - * 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' +/** Definition submitted to the preset registry. */ +export type Config = PresetDefinition ``` -来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) +依赖: [`PresetDefinition`](../packages/preset/agent-preset-registry/src/index.ts) + +来源: [`packages/preset/agent-preset/src/index.ts:9`](../packages/preset/agent-preset/src/index.ts) + + + +## `@deepseek-ai/dsh-agent-preset-registry` + +需要: `loader` · `sessionProjections` + +```ts config-catalog +/** Registry selection policy. */ +export interface Config { + /** Deployment default when the caller omits a preset. */ + default: string +} +``` + +来源: [`packages/preset/agent-preset-registry/src/preset.ts:12`](../packages/preset/agent-preset-registry/src/preset.ts) @@ -244,14 +229,14 @@ export interface Config { ## `@deepseek-ai/dsh-api-settings-controller` ```ts config-catalog -/** Native document-opening policy. */ -export interface Config { - /** Override platform desktop-opener detection. */ - readonly nativeOpen?: boolean +/** Host integrations replaceable by direct unit tests. */ +export interface SettingsControllerInternals { + /** Host text-editor integration used to open the settings document. */ + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise } ``` -来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) +来源: [`packages/api/settings-controller/src/index.ts:35`](../packages/api/settings-controller/src/index.ts) diff --git a/docs/dependency-catalog.json b/docs/dependency-catalog.json index dda5e77f28..d85e5782e7 100644 --- a/docs/dependency-catalog.json +++ b/docs/dependency-catalog.json @@ -529,11 +529,11 @@ ] }, { - "name": "@deepseek-ai/dsh-agent-presets", + "name": "@deepseek-ai/dsh-agent-preset-registry", "version": "0.1.5-rc.2", "installations": [ { - "location": "node_modules/@deepseek-ai/dsh-agent-presets", + "location": "node_modules/@deepseek-ai/dsh-agent-preset-registry", "direct": false, "optional": false, "peer": false diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index bb2e2b647b..8bdab546de 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 2b373821a2d0c9acf2284c4195a928947700f1ce -event-producer-consumer.zh.md: 90ca2a4c76ac477265a60dad80c5f65d04509e90 +event-producer-consumer.md: 741593e3f869994c260b580d9dddb18bdbf5193f +event-producer-consumer.zh.md: 323d37214254a54a9ca13d1918523cb1c92fd32a diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2b373821a2..741593e3f8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,9 +8,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | @@ -57,7 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -66,9 +66,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:147`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -89,7 +89,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | +| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `slots/changed` | `ui-renderer` (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 90ca2a4c76..323d372142 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,9 +10,9 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -43,9 +43,9 @@ | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | @@ -59,7 +59,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -68,9 +68,9 @@ | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:147`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -91,7 +91,7 @@ | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | +| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `slots/changed` | `ui-renderer` (`emit`) | - | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 9574d6c46b..ec94e38ea0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 8511f674c84bcfa11fb9e2b137b12025ec540c3e -module-graph.zh.md: 67ba8d7f6b2582200b8a2ecc2215f7b317880081 +module-graph.md: 936324af096fa6b17e260e9d9cd6d03a34fb9bd7 +module-graph.zh.md: c68f95c65af7df50dbfd61bf79d754c137c7f709 diff --git a/docs/module-graph.md b/docs/module-graph.md index 8511f674c8..936324af09 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -300,7 +300,8 @@ flowchart TD pkg_mcp_resources["mcp-resources"] end subgraph group_preset["packages/preset"] - pkg_agent_presets["agent-presets"] + pkg_agent_preset["agent-preset"] + pkg_agent_preset_registry["agent-preset-registry"] pkg_persona["persona"] end subgraph group_ptc_runtime["packages/ptc-runtime"] @@ -899,17 +900,15 @@ flowchart TD pkg_mcp_resources --> pkg_scope pkg_mcp_resources --> pkg_system_prompt pkg_mcp_resources --> pkg_tools - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_session_projection - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt - pkg_agent_presets --> pkg_tools - pkg_agent_presets --> pkg_typert_protocol + pkg_agent_preset_registry --> pkg_agent + pkg_agent_preset_registry --> pkg_invariants + pkg_agent_preset_registry --> pkg_scope + pkg_agent_preset_registry --> pkg_session + pkg_agent_preset_registry --> pkg_session_projection + pkg_agent_preset_registry --> pkg_settings + pkg_agent_preset_registry --> pkg_system_prompt + pkg_agent_preset_registry --> pkg_tools + pkg_agent_preset_registry --> pkg_typert_protocol pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -971,7 +970,7 @@ flowchart TD pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow pkg_plugin_package_inventory_deepseek --> pkg_agent - pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_agent_preset_registry pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions pkg_plugin_package_inventory_deepseek --> pkg_session pkg_token_meter --> pkg_compaction @@ -988,7 +987,7 @@ flowchart TD pkg_session_query --> pkg_session_projection_cache pkg_session_query --> pkg_session_title pkg_session_query --> pkg_tool_todo - pkg_api_settings_controller --> pkg_agent_presets + pkg_api_settings_controller --> pkg_agent_preset_registry pkg_api_settings_controller --> pkg_credentials pkg_api_settings_controller --> pkg_native_command pkg_api_settings_controller --> pkg_session @@ -1005,7 +1004,7 @@ flowchart TD pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner pkg_tool_cordis --> pkg_tools - pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_agent_preset_registry pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol pkg_mcp_client --> pkg_attachment @@ -1016,6 +1015,7 @@ flowchart TD pkg_mcp_client --> pkg_system_prompt pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools + pkg_agent_preset --> pkg_agent_preset_registry pkg_session_telemetry_otel --> pkg_anonymous_user_id pkg_session_telemetry_otel --> pkg_command_feedback pkg_session_telemetry_otel --> pkg_llm @@ -1060,7 +1060,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model - pkg_webhook --> pkg_agent_presets + pkg_webhook --> pkg_agent_preset_registry pkg_webhook --> pkg_invariants pkg_webhook --> pkg_llm pkg_webhook --> pkg_permission_presets @@ -1068,7 +1068,7 @@ flowchart TD pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_agent_preset_registry pkg_subagent --> pkg_attachment pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1182,7 +1182,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_tools pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model - pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_agent_preset_registry pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection @@ -1567,7 +1567,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | @@ -1579,15 +1579,16 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1595,8 +1596,8 @@ flowchart TD | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | +| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1612,7 +1613,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 67ba8d7f6b..c68f95c65a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -302,7 +302,8 @@ flowchart TD pkg_mcp_resources["mcp-resources"] end subgraph group_preset["packages/preset"] - pkg_agent_presets["agent-presets"] + pkg_agent_preset["agent-preset"] + pkg_agent_preset_registry["agent-preset-registry"] pkg_persona["persona"] end subgraph group_ptc_runtime["packages/ptc-runtime"] @@ -901,17 +902,15 @@ flowchart TD pkg_mcp_resources --> pkg_scope pkg_mcp_resources --> pkg_system_prompt pkg_mcp_resources --> pkg_tools - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_session_projection - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt - pkg_agent_presets --> pkg_tools - pkg_agent_presets --> pkg_typert_protocol + pkg_agent_preset_registry --> pkg_agent + pkg_agent_preset_registry --> pkg_invariants + pkg_agent_preset_registry --> pkg_scope + pkg_agent_preset_registry --> pkg_session + pkg_agent_preset_registry --> pkg_session_projection + pkg_agent_preset_registry --> pkg_settings + pkg_agent_preset_registry --> pkg_system_prompt + pkg_agent_preset_registry --> pkg_tools + pkg_agent_preset_registry --> pkg_typert_protocol pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -973,7 +972,7 @@ flowchart TD pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow pkg_plugin_package_inventory_deepseek --> pkg_agent - pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_agent_preset_registry pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions pkg_plugin_package_inventory_deepseek --> pkg_session pkg_token_meter --> pkg_compaction @@ -990,7 +989,7 @@ flowchart TD pkg_session_query --> pkg_session_projection_cache pkg_session_query --> pkg_session_title pkg_session_query --> pkg_tool_todo - pkg_api_settings_controller --> pkg_agent_presets + pkg_api_settings_controller --> pkg_agent_preset_registry pkg_api_settings_controller --> pkg_credentials pkg_api_settings_controller --> pkg_native_command pkg_api_settings_controller --> pkg_session @@ -1007,7 +1006,7 @@ flowchart TD pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner pkg_tool_cordis --> pkg_tools - pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_agent_preset_registry pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol pkg_mcp_client --> pkg_attachment @@ -1018,6 +1017,7 @@ flowchart TD pkg_mcp_client --> pkg_system_prompt pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools + pkg_agent_preset --> pkg_agent_preset_registry pkg_session_telemetry_otel --> pkg_anonymous_user_id pkg_session_telemetry_otel --> pkg_command_feedback pkg_session_telemetry_otel --> pkg_llm @@ -1062,7 +1062,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model - pkg_webhook --> pkg_agent_presets + pkg_webhook --> pkg_agent_preset_registry pkg_webhook --> pkg_invariants pkg_webhook --> pkg_llm pkg_webhook --> pkg_permission_presets @@ -1070,7 +1070,7 @@ flowchart TD pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_agent_preset_registry pkg_subagent --> pkg_attachment pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1184,7 +1184,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_tools pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model - pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_agent_preset_registry pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection @@ -1569,7 +1569,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | @@ -1581,15 +1581,16 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1597,8 +1598,8 @@ flowchart TD | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | +| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1614,7 +1615,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index da386eec26..42b628640b 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: 45fe3d7549b98a1c199e3bba1565fbf2c11b3620 -persistence-catalog.zh.md: f55524fdf2f0c4dc3167a89249be8e703bfca7c3 +persistence-catalog.md: 30fc6dc277bdbdaf5330740a8a291101358dbaa2 +persistence-catalog.zh.md: fff627b8ff3a8c1c97f7793b0abca7008f395eb0 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 45fe3d7549..30fc6dc277 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -198,7 +198,7 @@ Source: [`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types 'agent-preset/selected': { agentPreset: string } ``` -Source: [`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts) +Source: [`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -5501,7 +5501,7 @@ Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/ SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -Sources: [`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts) +Sources: [`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index f55524fdf2..fff627b8ff 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -200,7 +200,7 @@ export type SessionEvent = { 'agent-preset/selected': { agentPreset: string } ``` -来源:[`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -5503,7 +5503,7 @@ SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -来源:[`packages/preset/agent-presets/src/session.ts:28`](../packages/preset/agent-presets/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index e525fb8391..df74feb6a1 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -58852,7 +58852,7 @@ }, "names": [], "sources": [ - "packages/preset/agent-presets/src/session.ts:28" + "packages/preset/agent-preset-registry/src/session.ts:28" ] }, { diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 3c4fdb398a..1c711f241b 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: a1e876dcfcd31dfb9fd4840d0d0e217bb71e43bb -core.zh.md: a63e38cf6381d97656f2350cc16502030772b1a7 +core.md: fafe23cb473e30c74c17308f4ab79d982f609452 +core.zh.md: 5945720e38a89846ae14694dd7cf2b45af7433ad diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index a1e876dcfc..fafe23cb47 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -481,248 +481,91 @@ Types: [SessionHeader](persistence.md) Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop/src/index.ts) - + -### `ctx.agentPresets` — `AgentPresets` +### `ctx.agentPresets` — `AgentPresetRegistry` -Registry over the deployment's agent presets. - -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. +Registry of YAML-declared presets and the revisions live Agents retain. ```ts cordis-catalog -/** - * Every preset the configured roots currently supply. - * @returns the presets, first-root-wins per id. +/** Register and eagerly load a definition; activation failure remains visible in the roster. + * @param definition Parsed configuration supplied by the declaring plugin. + * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. + */ +async register(definition: PresetDefinition): Promise<() => Promise> + +/** Read every declared preset, including activation failures. + * @returns Display metadata and loading diagnostics. */ async list(): Promise -/** - * The roster off the Host: {@link list} projected to path-free rows, with - * the policy-effective default marked, this deployment's authoring - * capability, and its mode-selection policy beside it. - * - * Whether a client can open a preset's directory is the Host's own opener - * capability, not a roster property — a caller needing both joins them. - * @returns the rows, authoring capability, and effective selection policy. +/** Read the selection roster and chooser policy. + * @returns Current presets, default and chooser policy. */ @Remote('list') async remoteExportList(): Promise -/** - * Every preset's composition as flattened plugin rows, for plugin-listing - * surfaces beside the roster's own picker. - * - * A preset with a live standing mount answers from its newest generation's - * Loader entries — the composition new sessions join — even when the file - * behind it has since been edited into an unreadable state: the mount is - * what sessions actually run, so the broken verdict only applies to a - * preset nothing composed. One never composed since boot answers from its - * file, with `!!js` disabled gates evaluated against the Loader context so - * both answers reflect the same host. Reading never mounts: an unmounted - * preset is parsed, not composed, so listing a preset's plugins cannot - * activate them early. A composition that stopped reading between - * discovery's health verdict and this read is reported broken with the - * raced reason rather than dropped. - * @returns one composition per roster preset, in roster order. - */ -async compositionInventory(): Promise - -/** - * Resolve one preset by id. - * - * A broken preset resolves — deleting one, reading one, and reporting one - * all need the row — and the mounting paths refuse it AFTER resolution - * through {@link resolveMountable}. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the resolved preset. - * @throws when no configured root supplies that id. +/** Resolve an identity without starting an Agent. + * @param id Explicit preset or the current default. + * @returns Current metadata, including failure when activation failed. */ async resolve(id?: string): Promise -/** - * Compose one agent from a preset: ensure the preset's standing mount, then - * parent the agent's scope key to it so the mount's registrations and - * listeners cover this agent. - * - * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken preset never yields a half-composed - * session. - * @param agentCtx - the agent's scope context. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was composed, for the caller to record. - * @throws when the preset is unknown or its composition is unusable. +/** Bind an unpublished Agent to the current preset revision. + * @param ctx Agent context from its setup callback. + * @param id Requested preset, or the default. + * @returns Bound preset identity. */ -async mount(agentCtx: Context, id?: string): Promise +async mount(ctx: Context, id?: string): Promise -/** - * 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. +/** Join a child to the exact revision retained by its parent. + * @param ctx Child Agent context. + * @param parent Parent Agent context. + * @returns Inherited preset id, or undefined in a preset-free composition. */ -composeFrom(agentCtx: Context, parentCtx: Context): string | undefined +composeFrom(ctx: Context, parent: Context): string | undefined -/** - * 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. +/** Read the preset a live Agent uses. + * @param ctx Agent context. + * @returns Its preset id, if bound. */ -composedPreset(agentCtx: Context): string | undefined +composedPreset(ctx: Context): string | undefined -/** - * Read one preset's composition text. - * @param id - the preset id. - * @returns the composition exactly as stored. - * @throws when no configured root supplies that id. - */ -async read(id: string): Promise - -/** - * One preset's composition text with the roster row it belongs to. - * @param agentPreset - the preset id. - * @returns the composition beside its trust and published metadata. - * @throws {RemoteError} `gateway/bad-request` for an empty id, or - * `agent-preset/not-found` when no configured root supplies it. - */ -@Remote('read') async readDocument(agentPreset: string): Promise - -/** - * Create a locally authored preset by copying an existing one whole. - * - * Copy is the only authoring write. Composition text never crosses this - * seam: the source is named by id and its directory is copied as it stands, - * so the copy is exactly as loadable as its source and authoring grants no - * capability the roster did not already carry. The copy is NOT mounted to - * validate — a source that mounts today yields a copy that mounts today. - * @param from - the preset the copy starts from; shipped presets are the - * primary source, so any trust is accepted. - * @param id - the new preset's id, which becomes its directory name. - * @param name - display name for the copy; absent falls back to the id. - * @throws when the source is unknown, the id is unusable or already taken, - * or the deployment configures no writable root. - */ -async copy(from: string, id: string, name?: string): Promise - -/** - * Copy one preset through the Remote API. - * @param from - the source preset id. - * @param id - the new preset id. - * @param name - the copy's optional display name. - * @returns once the copy is stored. - * @throws {RemoteError} with the corresponding stable preset code and - * details when the copy is refused. - */ -@Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise - -/** - * Delete a locally authored preset. - * - * @param id - the preset id. - * @throws when the preset is unknown or ships with the deployment. - */ -async remove(id: string): Promise - -/** - * Delete one preset through the Remote API. - * @param id - the preset id. - * @returns once the preset is deleted. - * @throws {RemoteError} with the corresponding stable preset code and - * details when deletion is refused. - */ -@Remote('deletePreset') async remoteExportDelete(id: string): Promise - -/** - * One agent's instance of a service its preset mounted. - * - * A preset publishes services behind `isolate` realms, which are invisible - * outside the group that declares them — including to the host. This is how a - * caller holding the agent reads one anyway: a request that is ABOUT a - * session but arrives from outside it, which is every browser RPC. - * - * Read addressing only. A host row that `inject`s a service cannot use this, - * because injection resolves before any session exists and has no agent to - * key by; such a service belongs on the host plane instead. - * @param agent - the agent whose composition to look inside. - * @param name - the service name as the preset's rows resolve it. - * @returns the agent's instance, or undefined when its preset mounts none. +/** Read a service supplied inside an Agent's isolated preset group. + * @param agent Agent whose composition is queried. + * @param name Cordis service name. + * @returns The service, or undefined. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined -/** - * Re-link one agent to a different preset's standing composition. - * - * Only valid while the agent has produced nothing: swapping tools mid - * conversation would leave logged tool calls the new composition cannot - * make. The CALLER owns that check — this method does not read session - * history. - * - * The swap is a parent re-link, not an unmount: standing mounts are shared - * and permanent, so the old composition stays for its other agents and the - * new one is ensured BEFORE the link moves. An unknown or unusable preset - * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. The re-link runs through the binding this roster kept - * from the agent's mount — dsh-scope's only re-link authority. An agent - * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. A committed re-link emits - * `tools/change` because changing the parent scope changes the Agent's - * resolved tool set without adding or removing registry entries. - * @param agentCtx - the agent's scope context. - * @param id - the preset to compose the agent from instead. - * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable. +/** Rebind a blank Agent; the caller owns the blank-session check. + * @param ctx Agent context. + * @param id Requested preset. + * @returns The bound identity. */ -async recompose(agentCtx: Context, id: string): Promise +async recompose(ctx: Context, id: string): Promise -/** - * Compose a blank session's agent from a different preset and record it. - * @param agent - the session's live agent, resolved from the wire identity. - * @param agentPreset - the preset to compose the agent from instead. - * @returns the preset id that was recorded. - * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, - * `agent-preset/not-found`, or `agent-preset/invalid` when refused. +/** Select a preset before a session starts its first turn. + * @param agent Target Agent. + * @param agentPreset Requested identity. + * @returns Committed preset identity. */ @Remote('select') async select(agent: Agent, agentPreset: string): Promise -/** - * The standing scope key of one preset, for a host reader with no agent. - * - * A cold transcript read resolves tool presenters against the composition - * the session recorded, and the standing mount makes that possible without - * resuming anything: ensuring the mount composes plugins but starts no - * agent, no session, and no turn. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the standing scope key readers pass as a registry view scope. - * @throws when the preset is unknown or its composition is unusable. +/** Read current registrations for cold transcript presentation. + * @param id Preset identity or the default. + * @returns A revision lease; dispose it after the scoped read completes. */ -async standingKeyFor(id?: string): Promise +async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> + +/** Read plugin rows without creating an Agent. + * @returns Current declaration metadata and activation states. + */ +compositionInventory(): Promise ``` Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-preset-registry/src/index.ts`](../../packages/preset/agent-preset-registry/src/index.ts) @@ -1251,5 +1094,5 @@ One session committed a different agent preset to its durable log. Consumers inv 'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void ``` -Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts) +Source: [`packages/preset/agent-preset-registry/src/types.ts`](../../packages/preset/agent-preset-registry/src/types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index a63e38cf63..5945720e38 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -491,248 +491,91 @@ Types: [SessionHeader](persistence.zh.md) Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop/src/index.ts) - + -### `ctx.agentPresets` — `AgentPresets` +### `ctx.agentPresets` — `AgentPresetRegistry` -Registry over the deployment's agent presets. - -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. +Registry of YAML-declared presets and the revisions live Agents retain. ```ts cordis-catalog -/** - * Every preset the configured roots currently supply. - * @returns the presets, first-root-wins per id. +/** Register and eagerly load a definition; activation failure remains visible in the roster. + * @param definition Parsed configuration supplied by the declaring plugin. + * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. + */ +async register(definition: PresetDefinition): Promise<() => Promise> + +/** Read every declared preset, including activation failures. + * @returns Display metadata and loading diagnostics. */ async list(): Promise -/** - * The roster off the Host: {@link list} projected to path-free rows, with - * the policy-effective default marked, this deployment's authoring - * capability, and its mode-selection policy beside it. - * - * Whether a client can open a preset's directory is the Host's own opener - * capability, not a roster property — a caller needing both joins them. - * @returns the rows, authoring capability, and effective selection policy. +/** Read the selection roster and chooser policy. + * @returns Current presets, default and chooser policy. */ @Remote('list') async remoteExportList(): Promise -/** - * Every preset's composition as flattened plugin rows, for plugin-listing - * surfaces beside the roster's own picker. - * - * A preset with a live standing mount answers from its newest generation's - * Loader entries — the composition new sessions join — even when the file - * behind it has since been edited into an unreadable state: the mount is - * what sessions actually run, so the broken verdict only applies to a - * preset nothing composed. One never composed since boot answers from its - * file, with `!!js` disabled gates evaluated against the Loader context so - * both answers reflect the same host. Reading never mounts: an unmounted - * preset is parsed, not composed, so listing a preset's plugins cannot - * activate them early. A composition that stopped reading between - * discovery's health verdict and this read is reported broken with the - * raced reason rather than dropped. - * @returns one composition per roster preset, in roster order. - */ -async compositionInventory(): Promise - -/** - * Resolve one preset by id. - * - * A broken preset resolves — deleting one, reading one, and reporting one - * all need the row — and the mounting paths refuse it AFTER resolution - * through {@link resolveMountable}. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the resolved preset. - * @throws when no configured root supplies that id. +/** Resolve an identity without starting an Agent. + * @param id Explicit preset or the current default. + * @returns Current metadata, including failure when activation failed. */ async resolve(id?: string): Promise -/** - * Compose one agent from a preset: ensure the preset's standing mount, then - * parent the agent's scope key to it so the mount's registrations and - * listeners cover this agent. - * - * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken preset never yields a half-composed - * session. - * @param agentCtx - the agent's scope context. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was composed, for the caller to record. - * @throws when the preset is unknown or its composition is unusable. +/** Bind an unpublished Agent to the current preset revision. + * @param ctx Agent context from its setup callback. + * @param id Requested preset, or the default. + * @returns Bound preset identity. */ -async mount(agentCtx: Context, id?: string): Promise +async mount(ctx: Context, id?: string): Promise -/** - * 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. +/** Join a child to the exact revision retained by its parent. + * @param ctx Child Agent context. + * @param parent Parent Agent context. + * @returns Inherited preset id, or undefined in a preset-free composition. */ -composeFrom(agentCtx: Context, parentCtx: Context): string | undefined +composeFrom(ctx: Context, parent: Context): string | undefined -/** - * 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. +/** Read the preset a live Agent uses. + * @param ctx Agent context. + * @returns Its preset id, if bound. */ -composedPreset(agentCtx: Context): string | undefined +composedPreset(ctx: Context): string | undefined -/** - * Read one preset's composition text. - * @param id - the preset id. - * @returns the composition exactly as stored. - * @throws when no configured root supplies that id. - */ -async read(id: string): Promise - -/** - * One preset's composition text with the roster row it belongs to. - * @param agentPreset - the preset id. - * @returns the composition beside its trust and published metadata. - * @throws {RemoteError} `gateway/bad-request` for an empty id, or - * `agent-preset/not-found` when no configured root supplies it. - */ -@Remote('read') async readDocument(agentPreset: string): Promise - -/** - * Create a locally authored preset by copying an existing one whole. - * - * Copy is the only authoring write. Composition text never crosses this - * seam: the source is named by id and its directory is copied as it stands, - * so the copy is exactly as loadable as its source and authoring grants no - * capability the roster did not already carry. The copy is NOT mounted to - * validate — a source that mounts today yields a copy that mounts today. - * @param from - the preset the copy starts from; shipped presets are the - * primary source, so any trust is accepted. - * @param id - the new preset's id, which becomes its directory name. - * @param name - display name for the copy; absent falls back to the id. - * @throws when the source is unknown, the id is unusable or already taken, - * or the deployment configures no writable root. - */ -async copy(from: string, id: string, name?: string): Promise - -/** - * Copy one preset through the Remote API. - * @param from - the source preset id. - * @param id - the new preset id. - * @param name - the copy's optional display name. - * @returns once the copy is stored. - * @throws {RemoteError} with the corresponding stable preset code and - * details when the copy is refused. - */ -@Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise - -/** - * Delete a locally authored preset. - * - * @param id - the preset id. - * @throws when the preset is unknown or ships with the deployment. - */ -async remove(id: string): Promise - -/** - * Delete one preset through the Remote API. - * @param id - the preset id. - * @returns once the preset is deleted. - * @throws {RemoteError} with the corresponding stable preset code and - * details when deletion is refused. - */ -@Remote('deletePreset') async remoteExportDelete(id: string): Promise - -/** - * One agent's instance of a service its preset mounted. - * - * A preset publishes services behind `isolate` realms, which are invisible - * outside the group that declares them — including to the host. This is how a - * caller holding the agent reads one anyway: a request that is ABOUT a - * session but arrives from outside it, which is every browser RPC. - * - * Read addressing only. A host row that `inject`s a service cannot use this, - * because injection resolves before any session exists and has no agent to - * key by; such a service belongs on the host plane instead. - * @param agent - the agent whose composition to look inside. - * @param name - the service name as the preset's rows resolve it. - * @returns the agent's instance, or undefined when its preset mounts none. +/** Read a service supplied inside an Agent's isolated preset group. + * @param agent Agent whose composition is queried. + * @param name Cordis service name. + * @returns The service, or undefined. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined -/** - * Re-link one agent to a different preset's standing composition. - * - * Only valid while the agent has produced nothing: swapping tools mid - * conversation would leave logged tool calls the new composition cannot - * make. The CALLER owns that check — this method does not read session - * history. - * - * The swap is a parent re-link, not an unmount: standing mounts are shared - * and permanent, so the old composition stays for its other agents and the - * new one is ensured BEFORE the link moves. An unknown or unusable preset - * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. The re-link runs through the binding this roster kept - * from the agent's mount — dsh-scope's only re-link authority. An agent - * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. A committed re-link emits - * `tools/change` because changing the parent scope changes the Agent's - * resolved tool set without adding or removing registry entries. - * @param agentCtx - the agent's scope context. - * @param id - the preset to compose the agent from instead. - * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable. +/** Rebind a blank Agent; the caller owns the blank-session check. + * @param ctx Agent context. + * @param id Requested preset. + * @returns The bound identity. */ -async recompose(agentCtx: Context, id: string): Promise +async recompose(ctx: Context, id: string): Promise -/** - * Compose a blank session's agent from a different preset and record it. - * @param agent - the session's live agent, resolved from the wire identity. - * @param agentPreset - the preset to compose the agent from instead. - * @returns the preset id that was recorded. - * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, - * `agent-preset/not-found`, or `agent-preset/invalid` when refused. +/** Select a preset before a session starts its first turn. + * @param agent Target Agent. + * @param agentPreset Requested identity. + * @returns Committed preset identity. */ @Remote('select') async select(agent: Agent, agentPreset: string): Promise -/** - * The standing scope key of one preset, for a host reader with no agent. - * - * A cold transcript read resolves tool presenters against the composition - * the session recorded, and the standing mount makes that possible without - * resuming anything: ensuring the mount composes plugins but starts no - * agent, no session, and no turn. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the standing scope key readers pass as a registry view scope. - * @throws when the preset is unknown or its composition is unusable. +/** Read current registrations for cold transcript presentation. + * @param id Preset identity or the default. + * @returns A revision lease; dispose it after the scoped read completes. */ -async standingKeyFor(id?: string): Promise +async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> + +/** Read plugin rows without creating an Agent. + * @returns Current declaration metadata and activation states. + */ +compositionInventory(): Promise ``` Types: [ScopeKey](scope.zh.md) -Source: [`packages/preset/agent-presets/src/index.ts`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-preset-registry/src/index.ts`](../../packages/preset/agent-preset-registry/src/index.ts) @@ -1261,5 +1104,5 @@ One session committed a different agent preset to its durable log. Consumers inv 'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void ``` -Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts) +Source: [`packages/preset/agent-preset-registry/src/types.ts`](../../packages/preset/agent-preset-registry/src/types.ts) diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 94a8cc437f..22a45d5931 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.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 docs/subsystems/settings.md -settings.md: d8e3cbc46eb697315d4938b696e921a0c2e11828 -settings.zh.md: 772fd7832ff80cab9444c16c9a1dc7ae1875d51e +settings.md: 875b25f58dea82a2a69f2d4efa7696bdb39c257d +settings.zh.md: 88c50e5f991ec5b59a1f5b2ce3fc98c1bb84936d diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index d8e3cbc46e..875b25f58d 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -163,7 +163,7 @@ type SettingsUpdateSource = 'update' | 'provider' ## Native document operations -`SettingsDocumentOpenValue` confirms that `settings/openSettingsDocument` prepared the provider-owned document and handed it to the native text editor. `AgentPresetDirectoryOpenValue` reports either a completed native handoff or the resolved user-preset directory when desktop opening is unavailable. Neither operation accepts a browser-selected Host path. +`SettingsDocumentOpenValue` confirms that `settings/openSettingsDocument` prepared the provider-owned document and handed it to the native text editor. This operation does not accept a browser-selected Host path. @@ -291,12 +291,6 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue -/** - * Report whether this deployment can open an authored Agent preset directory natively. - * @returns true when the matching open operation is available. - */ -@Remote canOpenAgentPresetDirectory(): boolean - /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. @@ -336,15 +330,6 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @throws RemoteError when no document exists, preparation fails, or opening fails. */ @Remote async openSettingsDocument(signal: AbortSignal): Promise - -/** - * Open one user-authored Agent preset directory or return its path when no native opener exists. - * @param agentPreset - preset id resolved against Host-owned roots. - * @param signal - caller lifetime; abort terminates the native command. - * @returns an opened confirmation or the resolved directory for text display. - * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. - */ -@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 772fd7832f..88c50e5f99 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -163,7 +163,7 @@ type SettingsUpdateSource = 'update' | 'provider' ## 原生文档操作 -`SettingsDocumentOpenValue` 确认 `settings/openSettingsDocument` 已准备好 provider 持有的文档,并将其交给原生文本编辑器。`AgentPresetDirectoryOpenValue` 报告已完成的原生交接,或在桌面打开不可用时返回解析后的用户 preset 目录。两项操作都不接受由浏览器选择的 Host 路径。 +`SettingsDocumentOpenValue` 确认 `settings/openSettingsDocument` 已准备好 provider 持有的文档,并将其交给原生文本编辑器。此操作不接受由浏览器选择的 Host 路径。 @@ -291,12 +291,6 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue -/** - * Report whether this deployment can open an authored Agent preset directory natively. - * @returns true when the matching open operation is available. - */ -@Remote canOpenAgentPresetDirectory(): boolean - /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. @@ -336,15 +330,6 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @throws RemoteError when no document exists, preparation fails, or opening fails. */ @Remote async openSettingsDocument(signal: AbortSignal): Promise - -/** - * Open one user-authored Agent preset directory or return its path when no native opener exists. - * @param agentPreset - preset id resolved against Host-owned roots. - * @param signal - caller lifetime; abort terminates the native command. - * @returns an opened confirmation or the resolved directory for text display. - * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. - */ -@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 86b9e4b2b8..44ef4741f1 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -200,7 +200,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // No preset composition: the ACP bundle keeps 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"). + // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). let record: AcpSession try { record = await AcpSession.create(ctx, { diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 005d3fbc19..dae62fb90e 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -61,7 +61,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index d1220b9b93..f90d3ee6f0 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,7 +1,7 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from '@deepseek-ai/cordis' -import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' +import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote' @@ -31,7 +31,7 @@ export type { } from '@deepseek-ai/dsh-plugin-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' -export type {} from '@deepseek-ai/dsh-agent-presets/remote' +export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' @@ -65,7 +65,7 @@ export type {} from '@deepseek-ai/dsh-commands/types' export type {} from '@deepseek-ai/dsh-cordis-host-runner/types' export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' -export type {} from '@deepseek-ai/dsh-agent-presets/types' +export type {} from '@deepseek-ai/dsh-agent-preset-registry/types' export type {} from '@deepseek-ai/dsh-permission-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' export type {} from '@deepseek-ai/dsh-user-approval/types' diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4863750db5..781ffbb95a 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -23,7 +23,7 @@ import type {} from '@deepseek-ai/dsh-cordis-host-runner/types' import type {} from '@deepseek-ai/dsh-credentials/types' import type {} from '@deepseek-ai/dsh-goal/types' import type {} from '@deepseek-ai/dsh-llm/types' -import type {} from '@deepseek-ai/dsh-agent-presets/types' +import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index d7464526d3..911bc858b3 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -54,7 +54,7 @@ "path": "../../llm/llm" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../settings/settings" diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 958a70d828..7bf74e655e 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -36,7 +36,7 @@ "path": "../../goal/goal" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../extensions/cordis-host-runner" diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index a391fefa37..18cce123b6 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -79,7 +79,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -119,7 +119,7 @@ "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 8fd5255303..3f0fa77944 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -7,7 +7,7 @@ import type { Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 47fdbbdff8..54ebe1b334 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -1,7 +1,7 @@ /** Cold-safe Session list and search projection. */ import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' diff --git a/packages/api/session-controller/src/skill-catalog.ts b/packages/api/session-controller/src/skill-catalog.ts index ddf9a310af..d384052b3b 100644 --- a/packages/api/session-controller/src/skill-catalog.ts +++ b/packages/api/session-controller/src/skill-catalog.ts @@ -1,8 +1,7 @@ /** Session-addressed, cold-readable skill catalog Remote. */ import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-agent-presets/types' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { ScopeKey } from '@deepseek-ai/dsh-scope' @@ -72,7 +71,8 @@ export class SessionSkillCatalog extends TypertRemoteService { ) } - const scope = await this.scopeFor(sessionId, agentPreset) + await using lease = live === undefined ? await this.scopeFor(agentPreset) : undefined + const scope = live ?? lease?.key try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return { @@ -91,15 +91,12 @@ export class SessionSkillCatalog extends TypertRemoteService { /** Resolve a live or standing preset scope without creating an Agent. */ private async scopeFor( - sessionId: SessionId, agentPreset: string | undefined, - ): Promise { - const live = this.ctx.agents.get(sessionId) - if (live !== undefined) return live + ): Promise<({ key: ScopeKey } & AsyncDisposable) | undefined> { const presets = this.ctx.get('agentPresets') if (presets === undefined) return undefined try { - return await presets.standingKeyFor(agentPreset) + return await presets.acquireScope(agentPreset) } catch { // An unknown or unusable recorded preset falls back to the global registry. return undefined diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 75efa91401..4756ae45b0 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 3baed6c3b6..87bdc5a399 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 4fc3fd4f25..5406d235f8 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' @@ -29,7 +29,6 @@ function roster(ids: readonly string[]): unknown { const presetOf = (id: string): object => ({ id, trust: 'system', - path: `/presets/${id}/agent.cordis.yml`, }) return { defaultId: ids[0], diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 5b3dd18b42..ed4f4a6b0c 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry from '@deepseek-ai/dsh-agent' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index a7124c22c3..795a6c0b29 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -99,10 +99,10 @@ describe('SessionSkillCatalog', () => { description: 'Composed for this Agent.', invocation: { modelInvocable: false, userInvocable: true }, }])) - const standingKeyFor = vi.fn() + const acquireScope = vi.fn() ctx.provide('agentPresets', { serviceFor: () => ({ list: scopedList }), - standingKeyFor, + acquireScope, } as never) const catalog = new SessionSkillCatalog(ctx) @@ -114,7 +114,7 @@ describe('SessionSkillCatalog', () => { }], }) expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/project', scope: agent }) - expect(standingKeyFor).not.toHaveBeenCalled() + expect(acquireScope).not.toHaveBeenCalled() }) it('uses the recorded preset standing scope for a cold Session', async () => { @@ -127,14 +127,14 @@ describe('SessionSkillCatalog', () => { agentPreset: 'minimal', })), } as never) - const standingKeyFor = vi.fn(() => Promise.resolve(scope)) - ctx.provide('agentPresets', { standingKeyFor } as never) + const acquireScope = vi.fn(() => Promise.resolve({ key: scope, [Symbol.asyncDispose]: async () => {} })) + ctx.provide('agentPresets', { acquireScope } as never) const list = vi.fn(() => Promise.resolve([])) ctx.provide('skills', { list } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) - expect(standingKeyFor).toHaveBeenCalledWith('minimal') + expect(acquireScope).toHaveBeenCalledWith('minimal') expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope }) expect(ctx.agents.list()).toEqual([]) }) @@ -149,7 +149,7 @@ describe('SessionSkillCatalog', () => { })), } as never) ctx.provide('agentPresets', { - standingKeyFor: () => Promise.reject(new Error('unknown preset')), + acquireScope: () => Promise.reject(new Error('unknown preset')), } as never) const list = vi.fn(() => Promise.resolve([])) ctx.provide('skills', { list } as never) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 45a22e93cb..d7a73de3fa 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -38,7 +38,7 @@ { "path": "../../llm/llm" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, - { "path": "../../preset/agent-presets" }, + { "path": "../../preset/agent-preset-registry" }, { "path": "../../session/session-persistence" }, { "path": "../../session/session-projection" }, { "path": "../../session/session-projection-cache" }, diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 91bfea6d7f..fa1046191e 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/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/api/settings-controller/README.md -README.md: 761e4c43751675eec141a2efcf85caf52cc91590 -README.zh.md: c5a59f3eebfd1e6702c2abcd2407bc2ef4e725bd +README.md: b7472f6b0f32eeb6d5f674da7187ae35f7d96936 +README.zh.md: 9159e1f1678ec9a3fc282986ee89141e55ff3b4c diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index 761e4c4375..b7472f6b0f 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -29,7 +29,7 @@ Mount this package as a Loader entry in a profile that serves browser configurat `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. -`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.canOpenAgentPresetDirectory()` reports native-opening availability when the preset page becomes visible. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither open method accepts a browser-supplied filesystem target. +`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text editor; it accepts no browser-supplied filesystem target. ----- @@ -38,7 +38,6 @@ Mount this package as a Loader entry in a profile that serves browser configurat | Field | Default | Meaning | |---|---|---| -| `nativeOpen` | platform-detected | Whether Agent preset directories can be handed to a native desktop opener | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-settings-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index c5a59f3eeb..9159e1f167 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings 服务的三种写入操作,并返回该 namespace 的新脱敏视图;陈旧写入使用 `settings-conflict`,其他提供方拒绝使用 `settings-rejected`。 -`settings.openSettingsDocument()` 准备提供方持有的文档,并用原生文本编辑器意图将其打开。`settings.canOpenAgentPresetDirectory()` 在 preset 页面显示时报告原生打开能力。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并打开其目录,或在原生打开不可用时返回目录路径;两个打开方法都不接受浏览器提供的文件系统目标。 +`settings.openSettingsDocument()` 准备提供方持有的文档,并用原生文本编辑器打开;该方法不接受浏览器提供的文件系统目标。 ----- @@ -38,7 +38,6 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `nativeOpen` | 平台探测 | Agent preset 目录能否交给原生桌面打开器 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-settings-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index a10ed37838..819bcb12a0 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -49,7 +49,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -58,7 +58,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index c5e9037ab3..372f4ddf75 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -7,14 +7,8 @@ * @module @deepseek-ai/dsh-api-settings-controller */ -import { dirname } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import Schema from '@deepseek-ai/schemastery' -// Type-only: resolves the `agentPresets` Context augmentation this controller reads. -import type {} from '@deepseek-ai/dsh-agent-presets' import { - canOpenNativePath, - openNativePath, openNativeTextFile, } from '@deepseek-ai/dsh-native-command' import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings' @@ -25,19 +19,13 @@ import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typer import type { JsonValue } from '@deepseek-ai/dsh-util-values' import { z } from 'zod' import { CredentialsController } from './credentials.ts' -import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts' +import type { SettingsDocumentOpenValue } from './types.ts' export { CredentialsController } from './credentials.ts' export type * from './types.ts' const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) -/** Native document-opening policy. */ -export interface Config { - /** Override platform desktop-opener detection. */ - readonly nativeOpen?: boolean -} - /** Read abort state afresh after an awaited provider or opener call. */ function isAborted(signal: AbortSignal): boolean { return signal.aborted @@ -45,9 +33,8 @@ function isAborted(signal: AbortSignal): boolean { /** Host integrations replaceable by direct unit tests. */ export interface SettingsControllerInternals { - readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Host text-editor integration used to open the settings document. */ readonly openTextFile?: (path: string, signal: AbortSignal) => Promise - readonly canOpenPath?: () => boolean } /** @@ -86,11 +73,7 @@ declare module '@deepseek-ai/cordis' { * `settings/conflict` or `settings/rejected` with the service's message. */ export class SettingsController extends TypertRemoteService { - static Config: Schema = Schema.object({ nativeOpen: Schema.boolean() }) - - private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly openTextFile: (path: string, signal: AbortSignal) => Promise - private readonly canOpenPath: () => boolean /** * Register the settings namespace and mount the credentials namespace beside @@ -98,12 +81,9 @@ export class SettingsController extends TypertRemoteService { * return the configuration API's actionable missing-provider diagnostic. * @param ctx - Host context where settings and credential providers may be mounted. */ - constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) { + constructor(ctx: Context, internals: SettingsControllerInternals = {}) { super(ctx, 'settingsController', { namespace: 'settings' }) - this.openPath = internals.openPath ?? openNativePath this.openTextFile = internals.openTextFile ?? openNativeTextFile - this.canOpenPath = internals.canOpenPath - ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(CredentialsController) } @@ -123,15 +103,6 @@ export class SettingsController extends TypertRemoteService { } } - /** - * Report whether this deployment can open an authored Agent preset directory natively. - * @returns true when the matching open operation is available. - */ - @Remote - canOpenAgentPresetDirectory(): boolean { - return this.canOpenPath() - } - /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. @@ -215,48 +186,6 @@ export class SettingsController extends TypertRemoteService { } } - /** - * Open one user-authored Agent preset directory or return its path when no native opener exists. - * @param agentPreset - preset id resolved against Host-owned roots. - * @param signal - caller lifetime; abort terminates the native command. - * @returns an opened confirmation or the resolved directory for text display. - * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. - */ - @Remote - async openAgentPresetDirectory( - agentPreset: string, - signal: AbortSignal, - ): Promise { - if (agentPreset.length === 0) { - throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {}) - } - const presets = this.ctx.get('agentPresets') - if (presets === undefined) { - throw new RemoteError( - 'agent-preset/not-found', - 'this deployment composes no agent presets', - { agentPreset, available: [] }, - ) - } - const preset = await presets.resolve(agentPreset) - if (preset.trust !== 'user') { - throw new RemoteError( - 'agent-preset/read-only', - `agent-presets: preset "${preset.id}" cannot be written: it ships with the deployment`, - { agentPreset: preset.id, reason: 'it ships with the deployment' }, - ) - } - const directory = dirname(preset.path) - if (!this.canOpenPath()) return { opened: false, path: directory } - try { - await this.openPath(directory, signal) - return { opened: true } - } catch (error: unknown) { - if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) - throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error }) - } - } - private async write( ns: string, mode: 'update' | 'replace' | 'mutate', diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index cc9806aa84..2110184363 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -32,8 +32,3 @@ declare module '@deepseek-ai/dsh-typert-protocol' { export interface SettingsDocumentOpenValue { readonly opened: true } - -/** Result of opening or revealing one locally authored Agent preset directory. */ -export type AgentPresetDirectoryOpenValue = - | { readonly opened: true } - | { readonly opened: false; readonly path: string } diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index c5df993b59..6c30cd6e6d 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { SettingsDescriptor } from '@deepseek-ai/dsh-settings' -import { RemoteError, remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import SettingsController from '../src/index.ts' import { MemorySettings } from '../../../settings/settings/tests/memory.ts' @@ -76,12 +76,10 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(controller.typertRemote.namespace).toBe('settings') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, - { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } }, { method: 'update', invocation: { kind: 'direct' } }, { method: 'replace', invocation: { kind: 'direct' } }, { method: 'mutate', invocation: { kind: 'direct' } }, { method: 'openSettingsDocument', invocation: { kind: 'direct' } }, - { method: 'openAgentPresetDirectory', invocation: { kind: 'direct' } }, ]) }) @@ -245,7 +243,7 @@ describe('the settings Remote namespace a configuration page calls', () => { await ctx.plugin(DocumentSettings) const prepare = vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const controller = new SettingsController(ctx, {}, { openTextFile }) + const controller = new SettingsController(ctx, { openTextFile }) const signal = new AbortController().signal await expect(controller.openSettingsDocument(signal)).resolves.toEqual({ opened: true }) @@ -280,7 +278,7 @@ describe('the settings Remote namespace a configuration page calls', () => { const prepared = Promise.withResolvers() vi.spyOn(ctx.settings, 'prepareDocument').mockReturnValue(prepared.promise) const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const controller = new SettingsController(ctx, {}, { openTextFile }) + const controller = new SettingsController(ctx, { openTextFile }) const abort = new AbortController() const opening = controller.openSettingsDocument(abort.signal) @@ -295,7 +293,7 @@ describe('the settings Remote namespace a configuration page calls', () => { const ctx = new Context() await ctx.plugin(DocumentSettings) vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') - const controller = new SettingsController(ctx, {}, { + const controller = new SettingsController(ctx, { openTextFile: () => Promise.reject(new Error('no default editor')), }) @@ -319,7 +317,7 @@ describe('the settings Remote namespace a configuration page calls', () => { await opening.plugin(DocumentSettings) vi.spyOn(opening.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') const openAbort = new AbortController() - const openingController = new SettingsController(opening, {}, { + const openingController = new SettingsController(opening, { openTextFile: async () => { openAbort.abort(new Error('cancelled')) throw new Error('opening stopped') @@ -329,106 +327,4 @@ describe('the settings Remote namespace a configuration page calls', () => { .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) - it('opens a user Agent preset directory or returns its path without a native opener', async () => { - const ctx = new Context() - ctx.provide('agentPresets', { - resolve: (id: string) => Promise.resolve({ - id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, - }), - } as never) - const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath }) - expect(openable.canOpenAgentPresetDirectory()).toBe(true) - const signal = new AbortController().signal - await expect(openable.openAgentPresetDirectory('mine', signal)) - .resolves.toEqual({ opened: true }) - expect(openPath).toHaveBeenCalledWith('/presets/mine', signal) - - const headless = new Context() - headless.provide('agentPresets', { - resolve: (id: string) => Promise.resolve({ - id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, - }), - } as never) - const reveal = new SettingsController(headless, { nativeOpen: false }) - expect(reveal.canOpenAgentPresetDirectory()).toBe(false) - await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal)) - .resolves.toEqual({ opened: false, path: '/presets/mine' }) - }) - - it('covers native-open detection defaults and explicit overrides', () => { - const fromInjectedOpener = new SettingsController(new Context(), {}, { - openPath: () => Promise.resolve(), - }) - expect((fromInjectedOpener as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(true) - - const detected = new SettingsController(new Context()) - expect(typeof (detected as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe('boolean') - - const override = vi.fn(() => false) - const overridden = new SettingsController(new Context(), {}, { canOpenPath: override }) - expect((overridden as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(false) - expect(override).toHaveBeenCalledOnce() - }) - - it('refuses a shipped Agent preset and a missing preset provider', async () => { - const ctx = new Context() - ctx.provide('agentPresets', { - resolve: (id: string) => Promise.resolve({ - id, trust: 'system', path: `/presets/${id}/agent.cordis.yml`, - }), - } as never) - const controller = new SettingsController(ctx) - await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal)) - .rejects.toMatchObject({ code: 'agent-preset/read-only' }) - - const missing = new SettingsController(new Context()) - await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal)) - .rejects.toMatchObject({ code: 'agent-preset/not-found' }) - }) - - it('rejects an empty Agent preset id before resolving a provider', async () => { - const resolve = vi.fn() - const ctx = new Context() - ctx.provide('agentPresets', { resolve } as never) - const controller = new SettingsController(ctx) - - await expect(controller.openAgentPresetDirectory('', new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/bad-request' }) - expect(resolve).not.toHaveBeenCalled() - }) - - it('raises an Agent preset resolution failure as the roster reported it', async () => { - const ctx = new Context() - const reported = new RemoteError('agent-preset/not-found', 'no such preset', { - agentPreset: 'mine', available: ['standard'], - }) - ctx.provide('agentPresets', { resolve: async () => { throw reported } } as never) - const controller = new SettingsController(ctx) - - await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal)) - .rejects.toBe(reported) - }) - - it('classifies cancellation and non-Error failures from the preset opener', async () => { - const ctx = new Context() - ctx.provide('agentPresets', { - resolve: (id: string) => Promise.resolve({ - id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, - }), - } as never) - const abort = new AbortController() - const openPath = vi.fn() - .mockImplementationOnce(async () => { - abort.abort(new Error('cancelled')) - throw new Error('opening stopped') - }) - .mockRejectedValueOnce('desktop unavailable') - const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath }) - - await expect(controller.openAgentPresetDirectory('first', abort.signal)) - .rejects.toMatchObject({ code: 'gateway/cancelled' }) - await expect(controller.openAgentPresetDirectory('second', new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' }) - }) }) diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json index 0ea09072a4..6769591558 100644 --- a/packages/api/settings-controller/tsconfig.json +++ b/packages/api/settings-controller/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../credentials/credentials" diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index aecccea08b..5e62b222eb 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -192,7 +192,7 @@ function* liveEvents(session: Session): Generator { function currentPreset(header: AdoptableHeader, events: Iterable, sessionId: SessionId): string | undefined { let preset = header.agentPreset for (const event of events) { - // Owned by dsh-agent-presets, which this bundle does not compose, so the + // Owned by dsh-agent-preset-registry, which this bundle does not compose, so the // event is read structurally rather than through its module augmentation. const candidate = event as unknown as { type: string; data?: { agentPreset?: unknown } } if (candidate.type !== 'agent-preset/selected') continue @@ -334,7 +334,7 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise // This bundle composes no preset roster, so the model-facing rows sit in the // 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"). + // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). const setup = (agentCtx: Context): void => { const selected: ModelSelectionRef = { current: selection, assembled: undefined } installModelSelection(agentCtx, selected) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 4a92ad0991..5c8bd6b0f9 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -112,7 +112,7 @@ function appendTurn( }) } -/** Append the preset-selection event owned by dsh-agent-presets. */ +/** Append the preset-selection event owned by dsh-agent-preset-registry. */ function selectPreset(session: Session, agentPreset: string): void { const target = session as unknown as { append(type: string, data: unknown): void } target.append('agent-preset/selected', { agentPreset }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index ad5bbccf32..2add3cc772 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: 8fea1287610a0a71f2a351a1b8e00af87b20ba44 -README.zh.md: 603108991cd74ba379b3b9d8de9a2ff11bf7e101 +README.md: 983d7e662018f5ad1669c2ccf6c4f92a6b9c5ab1 +README.zh.md: a7a0c5638ef2a3ae59ca52fedd61a2c39fb3d805 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 8fea128761..983d7e6620 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -63,7 +63,7 @@ When you launch `dsh --profile web` over SSH, the URL line still prints but the ### Per-session agent setup -Each browser session composes its own agent from the shipped presets (the `standard` preset by default), instead of sharing one process-wide tool set. You can change the default preset or add your own presets under `$DSH_HOME/.agent-presets`. +Each browser session selects a shipped preset (`standard` by default). The Agent presets settings page changes the default and edits preset child plugins; saves persist in `$DSH_HOME/profiles/web/cordis.patch.yml`. Creator's plugin-management tool is enabled only when the Host provides an editable profile. ----- @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. +The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. ### Patch semantics @@ -93,7 +93,8 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon |---|---| | [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff | | [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--trusted-host`, `--no-open`, `--help` | -| [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, agent plane behind presets | +| [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry | +| [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file | | — | No runtime invariant companion is published; every contribution (frontend-static child plugin, prompt section, bashEnv registration) is registry-disposed with the fiber, and each owning registry's package carries that relation's invariant; the package holds no mutable state of its own to audit. | | [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 603108991c..a7a0c5638e 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -63,7 +63,7 @@ dsh --profile web --no-open --port 8080 ### 按会话的 agent 设置 -每个浏览器会话都从随发行版交付的 preset(默认 `standard`)组合自己的 agent,而不是共享一套进程级工具集。你可以更改默认 preset,或在 `$DSH_HOME/.agent-presets` 下添加自己的 preset。 +每个浏览器会话选择一个随发行版交付的 preset(默认 `standard`)。Agent 预设设置页可更改默认项并编辑预设的子插件;保存结果持久化到 `$DSH_HOME/profiles/web/cordis.patch.yml`。只有 Host 提供可编辑的 profile 时,Creator 的插件管理工具才会启用。 ----- @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 +此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 ### patch 语义 @@ -93,7 +93,8 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 |---|---| | [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 | | [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--trusted-host`、`--no-open`、`--help` | -| [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、由 preset 承载的 agent 层 | +| [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 | +| [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 | | — | 不发布运行时不变式伴生入口;每项贡献(frontend-static 子插件、提示词段落、bashEnv 注册)都会随 fiber 由注册表释放,且每个所属注册表的包负责该关系的不变式;本包不持有需要审计的可变状态。 | | [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、回退席位、提示词段落、就绪宣告 | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index b069c9bba2..c6a95d5939 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -512,15 +512,11 @@ - id: tool-web disabled: true -# The preset roster. The shipped presets are bundled inside -# `dsh-agent-presets` itself and prepended as a read-only `system` root -# (`includeShippedRoot`); `$DSH_HOME/.agent-presets` is where a person — or an -# agent — authors their own, appended by the same package (`includeUserRoot`), -# and carries the same trust as shell access because a preset IS a -# composition. This row only names the default and any deployment-added -# `roots`; no launcher patching is involved. +# The preset selection registry. The shipped preset declarations follow as +# separate patch files under `presets/`, listed after this file in +# `package.json` `dsh.bundle.patch`. - insert: - - id: agent-presets - name: '@deepseek-ai/dsh-agent-presets' + - id: agent-preset-registry + name: '@deepseek-ai/dsh-agent-preset-registry' config: default: standard diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 049587c941..ce0cbb44d5 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -23,6 +23,7 @@ "default": "./lib/startup.js" }, "./cordis.patch.yml": "./cordis.patch.yml", + "./presets/*.patch.yml": "./presets/*.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -30,20 +31,34 @@ "lib/index.js", "lib/startup.js", "cordis.patch.yml", + "presets/standard.patch.yml", + "presets/ptc.patch.yml", + "presets/minimal.patch.yml", + "presets/cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "MIT", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": [ + "./cordis.patch.yml", + "./presets/standard.patch.yml", + "./presets/ptc.patch.yml", + "./presets/minimal.patch.yml", + "./presets/cordis.patch.yml" + ] } }, "dependencies": { - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", + "@deepseek-ai/dsh-agent-preset": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", @@ -62,7 +77,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", - "@deepseek-ai/dsh-workspace-changes": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", @@ -74,6 +88,7 @@ "@deepseek-ai/dsh-client-ui-open-in-app": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-schedule": "workspace:^", @@ -81,14 +96,14 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", @@ -98,6 +113,10 @@ "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-compaction-basic": "workspace:^", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", @@ -111,21 +130,43 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-office-to-pdf": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-manager": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", + "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^", + "@deepseek-ai/dsh-workflow-ptc": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-workspace-changes": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", - "open": "^11.0.0", - "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-office-to-pdf": "workspace:^" + "open": "^11.0.0" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml new file mode 100644 index 0000000000..2f22d8c933 --- /dev/null +++ b/packages/bundle/web-app/presets/cordis.patch.yml @@ -0,0 +1,154 @@ +# Agent preset cordis: one `@deepseek-ai/dsh-agent-preset` declaration inserted +# after the web patch. Edits saved from the Web editor override this row's +# `config.plugins` by id from the profile patch. +- insert: + - id: preset-cordis + name: '@deepseek-ai/dsh-agent-preset' + config: + id: cordis + order: 4 + plugins: + # Same persona as `standard`: tool descriptions and the skill catalog carry + # the operating detail, and the skills own the preset-authoring rules. + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + suffix: Your working directory is {{cwd}}. + prefix: >- + You are a coding agent powered by the {{model}} model. + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + - id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + customSkillDirs: + - !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('@deepseek-ai/dsh-agent-preset/package.json')), 'skills') + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: !!js "!ctx.get('profileContext')" diff --git a/packages/bundle/web-app/presets/minimal.patch.yml b/packages/bundle/web-app/presets/minimal.patch.yml new file mode 100644 index 0000000000..ee36823e27 --- /dev/null +++ b/packages/bundle/web-app/presets/minimal.patch.yml @@ -0,0 +1,61 @@ +# Agent preset minimal: one `@deepseek-ai/dsh-agent-preset` declaration inserted +# after the web patch. Edits saved from the Web editor override this row's +# `config.plugins` by id from the profile patch. +- insert: + - id: preset-minimal + name: '@deepseek-ai/dsh-agent-preset' + config: + id: minimal + order: 3 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + prefix: You are a helpful software engineer assistant. + complete: true + includeRuntimeContext: false + - id: persistent-shell + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: pty + name: '@deepseek-ai/dsh-terminal' + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' + config: + timeoutMs: 300000 + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + - id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml new file mode 100644 index 0000000000..10b7ffc87f --- /dev/null +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -0,0 +1,152 @@ +# Agent preset ptc: one `@deepseek-ai/dsh-agent-preset` declaration inserted +# after the web patch. Edits saved from the Web editor override this row's +# `config.plugins` by id from the profile patch. +- insert: + - id: preset-ptc + name: '@deepseek-ai/dsh-agent-preset' + config: + id: ptc + order: 2 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + suffix: Your working directory is {{cwd}}. + prefix: You are a coding agent powered by the {{model}} model. + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + disabled: true + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + disabled: true + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: tool-presentation + name: '@deepseek-ai/dsh-agent-tool-presentation' + config: + mode: ptc + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: true diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml new file mode 100644 index 0000000000..0b898bf774 --- /dev/null +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -0,0 +1,146 @@ +# Agent preset standard: one `@deepseek-ai/dsh-agent-preset` declaration inserted +# after the web patch. Edits saved from the Web editor override this row's +# `config.plugins` by id from the profile patch. +- insert: + - id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + order: 1 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + suffix: Your working directory is {{cwd}}. + prefix: You are a coding agent powered by the {{model}} model. + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: true diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 51a0bbbf67..5642bff493 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -277,7 +277,7 @@ describe('connection node half', () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ 'session/openWorkspacePath', - 'llm/discoverModels', 'skills/list', 'settings/openAgentPresetDirectory', + 'llm/discoverModels', 'skills/list', 'agentPresets/list', ] for (const method of methods) { const denied = fakeResponse() @@ -645,7 +645,7 @@ describe('connection node half over a real HTTP server', () => { 'settings/openSettingsDocument', 'session/openWorkspacePath', 'llm/discoverModels', 'skills/list', - 'settings/openAgentPresetDirectory', + 'agentPresets/list', 'llm/listProviders', 'session/modelCatalog', ] for (const method of methods) { diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 5e9592ba70..99e647d2a1 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -61,7 +61,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index de88b1368c..d4dfb8777c 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-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/client/ui-agent-preset/README.md -README.md: f2c24e0b406c8c53bd49a1c72050f2f9c72c2300 -README.zh.md: 1b728834a35dcd756e9a044a4fb8274b7ea4c2ad +README.md: 9bc318e0a80abbf9562b29bc39701c4ce7edac55 +README.zh.md: 56da309c0f50e0510d941146d572db969dbe0c8b diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index f2c24e0b40..9bc318e0a8 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -1,5 +1,5 @@ --- -description: "Agent-preset surfaces for the Web GUI: picker visibility and default settings, the new-session chip, the session-header label, and preset roster management; for users and maintainers of agent composition." +description: "Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default while Developer tools is enabled; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. +Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode. ## Table of Contents @@ -25,21 +25,11 @@ Use this package to choose the agent preset for a new Web GUI session, see the a ## Use this package -The new-session picker and the management section’s “Choose a mode for new tasks” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. +Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted. The page edits nothing: the question icon beside the title opens a dialog explaining that presets are declared by installed bundles and, when the `cordis` preset is on the roster and a conversation flow exists, offers to start a Creator-mode task that authors or overrides a preset as a bundle. -Mount this plugin alongside the settings and conversation packages; once Developer tools is enabled, the management section shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. +The “Choose a mode for new tasks” switch controls whether the saved user default is active. Hiding selection uses the deployment default; showing it restores the user preference. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. The new-session picker additionally requires Developer tools in General Settings. -### Managing the roster - -The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The visibility switch changes only whether the saved user default is active: the Host uses the deployment default while hidden and restores the saved default when the picker is shown again. While the picker is enabled, choosing a healthy non-default card writes a new user default for later sessions; if the current new-task surface already reuses a blank session, that explicit Settings action carries the same preset to that exact blank session through the existing selection path. Started and historical sessions remain unchanged. The switch is disabled while saving, and a failed write keeps the prior preference and shows an error. Hiding the picker disables default selection and the Creator launch but leaves roster viewing, copying, location, and deletion available. Deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot. - -Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's source badge, and the preset id appears beside the title. Guide copy and examples belong to this package; the tab control comes from `ui-primitives`. - -### The conversational entry - -When the roster carries the self-referential `cordis` preset, its dashed add-card stays disabled until the picker is enabled. It then stages `cordis` and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces. - ------ +Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. ## Understand the implementation @@ -47,45 +37,31 @@ When the roster carries the self-referential `cordis` preset, its dashed add-car
Implementation internals — click to expand -The settings section writes the Host's existing `agent-presets` namespace through `settings.update`. Its visibility switch sets only `modeSelectionEnabled`, and its make-default action writes `default` only while the picker is shown. After either write, the Host roster supplies the effective default, and the chip controller's `agentPresets/select` path carries it to the same still-blank session; that path is the only session-mutation API these surfaces use. Display options and Host-effective visibility come from `agentPresets/list` — the roster already marks the effective Host default and carries `modeSelectionEnabled`, so a non-loopback read-only client stays consistent without introspecting the settings schema. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip renders only while `modeSelectionEnabled` is true; hiding it drops a pending stage and local menu or refusal state, while the header label remains registered and reads each session's recorded preset. The stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the Host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the Host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. - -The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. Settings synchronization creates the same bound controller when needed, even before the chip mounts. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection. Settings changes wait for that selection and recheck the captured blank Session before applying. A choice staged during a pending request remains available afterward. +`agentPresets/list` supplies the roster and the chooser policy; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
------ - ## Further Exploration -Read these pages when the preset surface is not enough. They move from the browser surfaces to the preset domain and the composition model. - -- [dsh-agent-presets](../../preset/agent-presets/README.md) — the host roster and composition the surfaces read and manage. -- [ui-conversation](../ui-conversation/README.md) — declares the hero and session-header slots the chip and label fill. -- [ui-settings](../ui-settings/README.md) — the settings shell that hosts the roster section. -- [Client package map](../README.md) — adjacent browser UI packages. - ------ +- [Scope](../../core/scope/README.md) — Registration isolation. +- [Agent](../../core/agent/README.md) — Session runtime. +- [Cordis](../../../docs/cordis-primer.md) — Plugin configuration and lifecycle. ## Model Experience -Indirectly, through the preset a later session is composed from; the preset it selects owns every model-facing effect. +Indirectly, through the selected preset, whose plugins own model-visible capabilities. #### KV Cache effect -No direct invalidation. Changing picker visibility or the default does not alter a running session's composition or prefix, or a historical session's recorded preset; a session created afterwards establishes its own prefix from its own composition. +Selection changes affect only later tasks; existing plugins and prompts remain unchanged. ## Known Limitations and Deferred Work - -These limits define the current preset surfaces. They are current package constraints, not a general composition comparison or a task backlog. - -- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source. The resolution itself is the shared `presetDisplayText` fold from [`dsh-agent-presets/display`](../../preset/agent-presets/README.md), which the Settings plugin list inlines over this plugin’s dictionaries to show shipped presets in the active locale without translating user-authored metadata. -- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself. -- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/document-updated`, and `connection/reset`, not on every disk edit. +- Web creates and edits no preset: a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. ### Dev Note @@ -97,4 +73,4 @@ None.
-**Runtime invariant:** No companion is published. This is a browser-side surface plugin whose node half owns no event stream or mutable runtime data; the roster and the settings write are host contracts covered there. +**Runtime invariant:** No companion is published; the Host registry owns state, and component tests cover client presentation and selection. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 1b728834a3..56da309c0f 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的 agent(智能体) preset 界面:选择器可见性与默认设置、新建会话 chip、会话标题标签与 preset 名单管理分区;供 agent 组装的用户与维护者阅读。" +description: "在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。" kind: "package-reference" --- @@ -9,92 +9,68 @@ kind: "package-reference" ## 概述 -使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器在通用设置开启开发者工具时默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 +在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。 ## 目录 -- [使用本包](#use-this-package) +- [使用此包](#use-this-package) - [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) +- [延伸阅读](#further-exploration) - [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [已知限制与待办](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ----- -## 使用本包 +## 使用此包 -新会话选择器和管理区域中的「新任务可选择模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 +设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示。本页不编辑任何内容:标题旁的问号图标打开一个对话框,说明 preset 由已安装的 bundle 声明;当 `cordis` preset 在列表中且存在会话流程时,可从对话框启动一个创造模式任务,以 bundle 形式创建或覆盖 preset。 -与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 +「新任务可选择模式」开关控制保存的用户默认值是否生效。关闭时使用部署默认值,重新开启则恢复用户偏好。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。新会话选择器还要求通用设置开启开发者工具。 -### 管理名单 - -设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。可见性开关只决定已保存的用户默认值是否生效:宿主在隐藏期间使用部署默认值,再次显示选择器时恢复已保存的默认值。选择器开启期间,选择健康且非默认的卡片会为后续会话写入新的用户默认值;如果当前新任务页已经复用一个空白会话,这次在设置中的明确选择也会通过既有选择链路把同一 preset 带到这个精确的空白会话。已开始及历史会话保持不变。保存期间开关会被禁用;写入失败时,界面保留先前的偏好并显示错误。隐藏选择器会禁用默认值选择与 Creator 启动,但名单查看、复制、位置和删除仍然可用。删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。 - -已知的内置预设提供模式说明与使用示例,通过只读弹窗展示。标签切换保留各页的滚动位置,关闭后焦点回到打开入口。查看帮助不会改变新任务默认模式。默认标签替换卡片的来源标签,预设标识显示在标题旁。说明文案和案例由本包维护,标签切换控件来自 `ui-primitives`。 - -### 对话式入口 - -名单携带自指的 `cordis` preset 时,其虚线添加卡在选择器开启前保持禁用。开启后,它会暂存 `cordis` 并启动新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。 - ------ +已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 ## 理解实现
-实现细节——点击展开 +实现细节 — 点击展开 -设置分区通过现有的 `settings.update` 写入宿主的 `agent-presets` 命名空间。可见性开关只设置 `modeSelectionEnabled`;仅当选择器显示时,设为默认动作才会写入 `default`。两种写入之后,都由宿主名单给出当前生效的默认值,再由 chip controller 的 `agentPresets/select` 链路把它带到同一个仍为空白的会话;这些界面只使用这一条会话修改 API。展示选项与宿主的生效可见性来自 `agentPresets/list`——名单本身已标记宿主当前生效的默认值并携带 `modeSelectionEnabled`,因此非 loopback 的只读客户端无需内省 settings schema 也能保持一致。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 仅在 `modeSelectionEnabled` 为 `true` 时渲染;隐藏它会丢弃待处理的暂存选择及本地菜单或失败横幅状态,而标题标签保持注册并读取每个会话已记录的 preset。暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有宿主 API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。 - -只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。即使选择芯片尚未挂载,设置同步也会按需创建同一个绑定控制器。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。设置修改会等待该操作结束,并在应用之前重新检查已捕获的空白 Session。请求期间暂存的新选择会保留。 +`agentPresets/list` 提供列表和选择器策略;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
------ - -## 进一步探索 +## 延伸阅读 -当 preset 界面无法满足需求时,请阅读以下页面。它们从浏览器界面延伸至 preset 领域与组装模型。 - -- [dsh-agent-presets](../../preset/agent-presets/README.zh.md)——这些界面读取并管理的宿主名单与组装。 -- [ui-conversation](../ui-conversation/README.zh.md)——声明 chip 与标签填充的首屏与会话头部槽位。 -- [ui-settings](../ui-settings/README.zh.md)——承载名单分区的设置外壳。 -- [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 - ------ +- [Scope](../../core/scope/README.zh.md) — 注册隔离。 +- [Agent](../../core/agent/README.zh.md) — 会话运行时。 +- [Cordis](../../../docs/cordis-primer.zh.md) — 插件配置与生命周期。 ## 模型体验 -间接影响,经由此后会话据以组装的 preset;它所选择的 preset 拥有所有面向模型的效果。 +通过选定的 preset 间接影响模型,其插件拥有模型可见能力。 -#### KV Cache 影响 +#### KV Cache effect -没有直接的失效影响。更改选择器可见性或默认值不会改变运行中会话的组装或前缀,也不会改变历史会话已记录的 preset;此后创建的会话依据它自己的组装建立自己的前缀。 +选择变更只影响之后的任务,已有插件与提示词保持不变。 -## 已知限制与延期工作 +## 已知限制与待办 - -这些限制界定了当前 preset 界面。它们是当前包约束,不是通用组装对比或任务积压。 - -- **没有元数据的 preset 按 id 列出**——展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。解析本身使用 [`dsh-agent-presets/display`](../../preset/agent-presets/README.zh.md) 共享的 `presetDisplayText` 解析逻辑,设置的插件列表把它内联在本插件的字典之上,按当前语言显示随附 preset 的名称,同时不翻译用户自建的元数据。 -- **展示的路径是文本,不是链接**——宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。 -- **组装编辑对页面不可见**——文件在浏览器之外编辑,协议链路不广播文件变动,因此名单只在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,而非每次磁盘编辑。 +- Web 不创建也不编辑 preset:通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 ### 开发备注
-维护者的工作上下文——点击展开 +维护者工作上下文 — 点击展开 无。
-**运行时不变式:** 不发布伴生入口。这是浏览器侧界面插件,Node 侧不拥有事件流或可变运行时数据;名单与设置写入属于宿主约定。 +**运行时不变量:** 不发布 companion;状态由 Host 注册表拥有,客户端展示与选择行为由组件测试验证。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 067879042b..7a7932feee 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -49,29 +49,30 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" + "react": "^18.2.0" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": {} } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 34b3879a60..26fdcfcbf6 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -14,7 +14,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli import { IconAgentPresetOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type {} from '@deepseek-ai/dsh-agent-presets/types' +import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import type { AgentPresetSettingsState } from './settings-store.ts' import { presetDisplayText } from './locales.ts' import css from './AgentPresetLabel.module.css' diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index c60115ee01..ebc3f064b9 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -51,16 +51,7 @@ const INTRO_CHAR_STAGGER_MS = 40 const INTRO_TEXT_REVEAL_MS = 200 const INTRO_CHAR_FADE_MS = 400 -/** - * How long a refused switch holds before fading. - * - * Longer than the primitive's default because this banner is the only place - * the refusal appears. The chip's label has already snapped back to the - * preset the session still runs, and a preset the host refuses to MOUNT is - * one discovery reported healthy — its row on the settings page carries no - * reason to go back and read, because there was nothing to see until the - * rows actually ran. - */ +/** Duration of a selection-refusal banner, including a revision becoming unavailable during a pick. */ const REFUSAL_HOLD_MS = 8000 /** diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 76e6b536e3..db8faca7ba 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -6,6 +6,12 @@ color: var(--dsw-alias-label-primary); } +.titleRow { + display: flex; + align-items: center; + gap: 6px; +} + .title { margin: 0; font-size: 18px; @@ -370,161 +376,8 @@ opacity: 1; } -.iconDanger:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - -/* Where the host has no desktop opener, the row answers with the directory - itself — text to copy, not a control that would spawn into nothing. */ -.revealedPath { - margin: 0; - padding: 6px 16px 10px; - font-size: 11px; - color: var(--dsw-alias-label-tertiary); - display: flex; - gap: 6px; - align-items: baseline; -} - -.revealedPath code { - font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - color: var(--dsw-alias-label-secondary); - user-select: all; - overflow-wrap: anywhere; -} - -.revealedPathLabel { - white-space: nowrap; -} - -.secondaryButton { - border: none; - border-radius: 7px; - padding: 5px 8px; - background: none; - color: var(--dsw-alias-label-secondary); - font: inherit; - font-size: 12.5px; - cursor: pointer; -} - - -.secondaryButton:hover:not(:disabled) { - background: var(--dsw-alias-bg-layer-1); -} - -.secondaryButton:disabled { - opacity: 0.5; - cursor: default; -} - -.field { - display: flex; - flex-direction: column; - gap: 6px; -} - -.fieldLabel { - font-size: 12px; - font-weight: 500; - color: var(--dsw-alias-label-secondary); -} - -.input { - box-sizing: border-box; - padding: 9px 12px; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 10px; - font: inherit; - font-size: 13px; - background: var(--dsw-alias-bg-layer-1); - color: var(--dsw-alias-label-primary); -} - -.input:focus { - outline: none; - border-color: var(--dsw-alias-brand-primary); -} - -.input::placeholder { - color: var(--dsw-alias-label-dimmed); -} - -.dialog { - width: min(560px, 100%); -} - -.dialogFields { - display: flex; - flex-direction: column; - gap: 12px; -} - -/* A shipped composition can be long; the dialog scrolls it rather than grow. */ -.viewerCode { - margin: 0; - padding: 12px; - max-height: min(52vh, 480px); - overflow: auto; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 10px; - background: var(--dsw-alias-bg-layer-2); - color: var(--dsw-alias-label-secondary); - font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 12.5px; - line-height: 1.5; - white-space: pre; - tab-size: 2; - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); -} - .error { margin: 0; font-size: 12px; color: var(--dsw-alias-state-error-primary); } - -.deleteDialog { - width: min(480px, 100%); -} - -.deleteConfirm:not(:disabled) { - border-color: var(--dsw-alias-state-error-primary); - color: var(--dsw-alias-state-error-primary); -} - -.deleteConfirm:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); -} - -/* The conversational authoring entry, after the card grid in the spot the - create button vacated. Dashed like the Models page's add affordances: it - reads as a place a preset will appear, not a command. */ -.creatorButton { - box-sizing: border-box; - align-self: stretch; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 44px; - border: 1px dashed var(--dsw-alias-border-l3); - border-radius: 20px; - font: inherit; - font-size: 14px; - line-height: 22px; - background: transparent; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - -.creatorButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.creatorButton:disabled { - opacity: 0.4; - cursor: default; -} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index cc3e9157ff..53388f5025 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,155 +1,30 @@ -/** - * Agent-presets settings section: the roster as cards, a copy dialog as the - * only way a preset is created, and a read-only viewer over the shipped - * compositions. - * - * The browser edits no composition text — a shipped preset opens read-only to - * be READ (it is the known-good composition a copy starts from), and a custom - * preset is edited in its own files, which is what the location action leads - * to. Deleting a preset leaves running sessions alone: a composition is - * mounted once at session creation and nothing re-reads the file. - */ - -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +/** Preset selection settings: the roster, its default, mode help and the Creator-mode entry. */ import type { ReactNode } from 'react' -import { - Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular, - IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Button, IconQuestionOutlineRegular, Modal, Switch, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' -import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' +import type { AgentPresetSectionState } from './section-store.ts' +import { isBuiltInPreset, presetDisplayText } from './locales.ts' import { PresetGuideDialog, presetGuide, type PresetGuidePage } from './PresetGuideDialog.tsx' import css from './AgentPresetSection.module.css' -/** Registration-side business face for the management section. */ +/** Settings actions and their shared controller state. */ export interface AgentPresetSectionInjected { hooks: { - /** Page snapshot bound by the renderer as useAgentPresetSection. */ agentPresetSection: SnapshotStore /** Shared preference controlling the picker-policy row. */ developerTools: ObservableSnapshot } - /** Read the roster; called once when the section first renders. */ - load: () => Promise - /** Open one shipped preset's composition in the read-only viewer. */ - view: (id: string) => Promise - /** Close the read-only viewer. */ - closeView: () => void - /** Open the copy dialog over one preset. */ - beginCopy: (from: string) => void - /** Close the copy dialog, discarding the draft. */ - cancelCopy: () => void - /** Name the preset the copy creates. */ - setCopyId: (id: string) => void - /** Name the copy's display name. */ - setCopyName: (name: string) => void - /** Submit the copy. */ - confirmCopy: () => Promise - /** Open one preset's directory, or reveal its path where there is no desktop. */ - openLocation: (id: string) => Promise - /** - * Stage the self-referential preset and start a new session on it — the - * guided way to author a preset, beside copying. Absent when the surface - * is composed without the conversation flow to land the session in. - */ + /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void - /** Ask for delete confirmation, or dismiss it with null. */ - confirmDelete: (id: string | null) => void - /** Delete the preset awaiting confirmation. */ - remove: () => Promise - /** Make one preset the default for sessions created later. */ + load: () => Promise makeDefault: (id: string) => Promise - /** Show or hide preset selection on new-session surfaces. */ - setPickerVisible: (showPicker: boolean) => Promise + setPickerVisible: (visible: boolean) => Promise } +/** Props assembled by the settings renderer. */ +export type AgentPresetSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'settings.agentPreset'> & InjectFace -/** Full component props. */ -export type AgentPresetSectionProps = - PropsRuntime<'settings.section'> - & PropsLocale<'settings.agentPreset'> - & InjectFace - -/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */ -interface CopyDialogProps { - state: AgentPresetSectionState - t: (key: AgentPresetSettingsKey) => string - actions: Pick -} - -function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { - const draft = state.copy - const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) - const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) - const source = draft === null ? undefined : state.rows.find(row => row.id === draft.from) - const sourceTitle = source === undefined ? draft?.fromTitle : presetDisplayText(source, t).name - return ( - { actions.cancelCopy() }} - title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${sourceTitle}`} - closeLabel={t('close')} - description={t('copyIntro')} - className={css.dialog as string} - footer={( - <> - - - - )} - > - {draft === null - ? null - : ( -
- - - {message === null ? null :

{message}

} -
- )} -
- ) -} - -/** - * Render one card's description, clamped by CSS and offered in full on hover. - * The tooltip is attached only while the text is actually cut off, so a short - * description does not answer a hover with a bubble repeating the card. - * @param props.text - the description as rendered, already localized. - * @returns the description element, tooltip-anchored while it overflows. - */ function CardDescription({ text }: { text: string }): ReactNode { const ref = useRef(null) const [truncated, setTruncated] = useState(false) @@ -176,347 +51,105 @@ function CardDescription({ text }: { text: string }): ReactNode { ) } -/** - * Render the Agent presets section content column. - * @param props - composed slot props. - * @returns the section, or null when the deployment composes no presets. +/** Render the roster with its default, mode help, and the guidance to Creator mode. + * @param props Settings actions, snapshot hooks and localized text. + * @returns The preset settings section. */ -export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { - const { useAgentPresetSection, t, load } = props - const state = useAgentPresetSection(snapshot => snapshot) - const developerTools = props.useDeveloperTools(enabled => enabled) +export function AgentPresetSection({ + useAgentPresetSection, load, makeDefault, setPickerVisible, startCreatorDraft, close: closeSettings, useDeveloperTools, t, +}: AgentPresetSectionProps) { + const state = useAgentPresetSection(value => value) + const developerTools = useDeveloperTools(enabled => enabled) + const [authoring, setAuthoring] = useState(false) const [guide, setGuide] = useState<{ content: NonNullable> page: PresetGuidePage } | null>(null) - const viewedId = state.view?.id - const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId) - const viewedTitle = state.view === null - ? '' - : viewedRow === undefined ? state.view.title : presetDisplayText(viewedRow, t).name - - useEffect(() => { - void load() - }, [load]) - - // A deployment that composes no presets has nothing to manage: every - // session shares the host composition and the page would be an empty list. - if (state.status === 'unavailable') return null - if (state.status === 'error') { - /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */ - const detail = state.error ?? '' - return ( -
-

{`${t('error')} ${detail}`}

- -
- ) - } - - /* The guided alternative to copying: the self-referential preset can - read this very composition and author a new one in conversation. - Offered only where that preset is actually on the roster and a - session can be landed; without a writable root the draft could - never be discovered, so the reason rides the disabled button. */ - const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') - ? ( - - ) - : null - - return ( -
+ useEffect(() => { void load() }, [load]) + // Creator mode authors presets in conversation; it needs the flow to land a + // session in and the self-referential preset on the roster. + const creator = startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') ? startCreatorDraft : undefined + return
+

{t('nav')}

-

{t('sectionIntro')}

- {developerTools && ( -
-
- - {t('showPicker')} - {t('showPickerBeta')} - -

{t('showPickerDescription')}

-
- { void props.setPickerVisible(next) }} - /> -
- )} - {state.error === null ? null :

{state.error}

} - {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { - const group = state.rows - .filter(row => row.trust === trust) - .map(row => ({ - row, - text: presetDisplayText(row, t), - help: presetGuide(row.id, row.trust), - selectionAction: row.broken !== undefined - ? t('brokenBadge') - : row.isDefault - ? t(state.showPicker ? 'inUse' : 'selectionOffDefault') - : t(state.showPicker ? 'setDefault' : 'enablePickerToSetDefault'), - })) - // The custom group is where a preset of one's own will appear, so it - // stays on screen even while empty: heading plus the creator entry. - const tail = trust === 'user' ? creatorButton : null - if (group.length === 0 && tail === null) return null - return ( -
-

{heading}

- {group.length === 0 ? null : ( -
    - {group.map(({ row, text, selectionAction, help }) => ( -
  • - {/* The card body IS the control: picking a preset is the - common act, so it should not hide behind a small button. - The action row sits outside it — nesting buttons is - invalid, and these act on the card rather than select it. - A broken preset cannot compose a session, so its body - refuses the pick; the reason rides the badge rather than - the card face, which stays the preset's own - description. */} - -
    - {help === undefined ? null : ( -
    - - -
    - )} - {/* Shipped presets are the compositions a copy starts - from, so READING one is the point; a custom preset is - edited in its files instead, which the location action - leads to. A broken shipped preset has no readable - composition to offer, so its viewer is withheld; a - broken custom one keeps the location action — the - files are where it gets fixed. */} - {row.trust === 'system' - ? row.broken === undefined - ? ( - - ) - : null - : ( - - )} - - {row.trust === 'user' - ? ( - - ) - : null} -
    - {state.revealedPaths[row.id] === undefined - ? null - : ( -

    - {t('revealedPathLabel')} - {state.revealedPaths[row.id]} -

    - )} -
  • - ))} -
- )} - {tail} -
- ) - })} - {guide === null ? null : ( - { setGuide(null) }} - /> - )} - - { props.closeView() }} - title={state.view === null ? '' : `${t('view')} · ${viewedTitle}`} - closeLabel={t('close')} - description={t('composition')} - className={css.dialog as string} - footer={( - - )} - > - {state.view === null - ? null - :
{state.view.content}
} -
- { props.confirmDelete(null) }} - title={t('deleteTitle')} - closeLabel={t('close')} - description={t('deleteDescription')} - className={css.deleteDialog as string} - footer={( - <> - - - - )} - /> +
- ) +

{t('sectionIntro')}

+ {developerTools && ( +
+
+ + {t('showPicker')} + {t('showPickerBeta')} + +

{t('showPickerDescription')}

+
+ { void setPickerVisible(value) }} label={t('showPicker')} /> +
+ )} + {state.error === null ? null :

{state.error}

} + {([true, false] as const).map((builtIn) => { + const rows = state.rows.filter(row => isBuiltInPreset(row) === builtIn) + if (rows.length === 0) return null + return
+

{t(builtIn ? 'builtInGroup' : 'customGroup')}

+
    + {rows.map((row) => { + const display = presetDisplayText(row, t) + const help = presetGuide(row.id, builtIn ? 'system' : 'user') + const selectionAction = row.broken !== undefined ? t('brokenBadge') + : row.isDefault ? t(state.showPicker ? 'inUse' : 'selectionOffDefault') + : t(state.showPicker ? 'setDefault' : 'enablePickerToSetDefault') + return
  • + + {help === undefined ? null :
    +
    + + +
    +
    } +
  • + })} +
+
+ })} + {guide === null ? null : { setGuide(null) }} />} + { setAuthoring(false) }} + footer={<> + + {creator === undefined ? null : } + }> +

{t('authoringBody')}

+
+
} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 0838fd89aa..882430d19c 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -49,9 +49,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' export type { AgentPresetSeatState } from './seat-store.ts' -export { - draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView, -} from './section-store.ts' +export type { AgentPresetSectionState } from './section-store.ts' export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' @@ -96,11 +94,7 @@ export function apply(ctx: ClientContext): void { boundSeatDisposers.add(dispose) return seat } - const section = new AgentPresetSectionController(ctx, () => { - void controller.load() - void unboundSeat.load() - for (const seat of seats.values) void seat.load() - }) + const section = new AgentPresetSectionController(ctx) const mainBlankSeat = (): AgentPresetSeatController | undefined => { const summary = Object.values(ctx.sessions.list.getSnapshot().byId) .find((session) => { @@ -114,7 +108,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') ctx.effect(() => { - // The roster is a live directory and the default is a settings field, so + // The roster reflects live declarations and the default is a settings field, so // both an external settings edit and a reconnect can move this row. const refresh = (): void => { void controller.load() @@ -205,17 +199,7 @@ export function apply(ctx: ClientContext): void { const sectionInjected = (): AgentPresetSectionInjected => ({ hooks: { agentPresetSection: section.store, developerTools: ctx.settingsScope.developerTools.enabled }, load: () => section.load(), - view: (id: string) => section.view(id), - closeView: () => { section.closeView() }, - beginCopy: (from: string) => { section.beginCopy(from) }, - cancelCopy: () => { section.cancelCopy() }, - setCopyId: (id: string) => { section.setCopyId(id) }, - setCopyName: (name: string) => { section.setCopyName(name) }, - confirmCopy: () => section.confirmCopy(), - openLocation: (id: string) => section.openLocation(id), ...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft }, - confirmDelete: (id: string | null) => { section.confirmDelete(id) }, - remove: () => section.remove(), makeDefault: (id: string) => section.makeDefault(id, captureBlankSessionSync()), setPickerVisible: (showPicker: boolean) => section.setPickerVisible(showPicker, captureBlankSessionSync()), }) diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 4d15827ecc..5076df47db 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -5,38 +5,52 @@ import { guideEn, guideZh, type PresetGuideKey } from './guide-locales.ts' /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = | PresetGuideKey - | 'error' | 'userTrust' | 'seatHint' | 'headerHint' - | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' - | 'presetStandardName' | 'presetStandardDescription' - | 'presetPtcName' | 'presetPtcDescription' - | 'presetMinimalName' | 'presetMinimalDescription' - | 'presetCordisName' | 'presetCordisDescription' - | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' - | 'displayName' | 'displayNamePlaceholder' - | 'inUse' | 'selectionOffDefault' | 'noDescription' | 'builtInGroup' | 'customGroup' - | 'brokenBadge' | 'brokenNoCopy' | 'switchRefused' - | 'composition' | 'cancel' | 'close' | 'retry' - | 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft' - | 'openLocation' | 'showLocation' | 'revealedPathLabel' - | 'idRequired' | 'idInvalid' | 'idTaken' - | 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting' - | 'showPicker' | 'showPickerBeta' | 'showPickerDescription' - | 'enablePickerToSetDefault' | 'enablePickerToCreate' + | 'builtInGroup' + | 'customGroup' + | 'seatHint' + | 'headerHint' + | 'nav' + | 'sectionIntro' + | 'authoringHelp' + | 'authoringTitle' + | 'authoringBody' + | 'setDefault' + | 'presetStandardName' + | 'presetStandardDescription' + | 'presetPtcName' + | 'presetPtcDescription' + | 'presetMinimalName' + | 'presetMinimalDescription' + | 'presetCordisName' + | 'presetCordisDescription' + | 'inUse' + | 'selectionOffDefault' + | 'noDescription' + | 'brokenBadge' + | 'switchRefused' + | 'close' + | 'creatorDraft' + | 'showPicker' + | 'showPickerBeta' + | 'showPickerDescription' + | 'enablePickerToSetDefault' + | 'enablePickerToCreate' /** English copy. */ export const en: Record = { ...guideEn, - error: 'Could not load agent presets.', - userTrust: 'Custom', + builtInGroup: 'Built-in', customGroup: 'Custom', + sectionIntro: 'Choose the agent’s tools and how it works. Use Standard mode for everyday tasks, or Creator mode to add capabilities to DSH.', + authoringHelp: 'How to create or change a preset', + authoringTitle: 'Creating and changing presets', + authoringBody: 'Presets are declared by installed bundles, so this page only chooses among them. To create a preset or change one, start a task in Creator mode: the agent writes a bundle that declares or overrides the preset and installs it into the current profile.', + seatHint: 'Choose the agent preset for your new task', headerHint: 'The agent preset chosen when this task started', nav: 'Agent presets', - sectionIntro: - 'Choose the agent\'s tools and how it works. Use Standard mode for everyday tasks, ' - + 'or Creator mode to add capabilities to DSH.', - builtIn: 'Built-in', + setDefault: 'Set as new task default', - view: 'View configuration', + presetStandardName: 'Standard mode', presetStandardDescription: 'Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed.', @@ -49,64 +63,42 @@ export const en: Record = { presetCordisName: 'Creator mode', presetCordisDescription: 'Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode.', - duplicate: 'Duplicate preset', - duplicateUnavailable: 'This deployment has no writable preset directory', - delete: 'Delete', - presetId: 'Identifier', - presetIdPlaceholder: 'my-agent', - displayName: 'Name', - displayNamePlaceholder: 'Name shown in the mode menu; leave blank to use the identifier', + inUse: 'New task default', selectionOffDefault: 'Application default', - builtInGroup: 'Built-in', - customGroup: 'Custom', + noDescription: 'No description.', brokenBadge: 'Failed to load', - brokenNoCopy: 'A preset that failed to load cannot be duplicated', + switchRefused: 'Could not switch to {name}: {reason}', - copyOf: 'Copied from', - composition: 'Preset configuration (agent.cordis.yml)', - cancel: 'Cancel', + close: 'Close', - retry: 'Retry', - copyTitle: 'Duplicate preset', - copyIntro: - 'Save a copy on this machine, then open its folder to edit the configuration. ' - + 'The identifier becomes the folder name and cannot be changed after creation.', - create: 'Create', - creating: 'Creating…', + creatorDraft: 'Let the agent help me create a preset', - openLocation: 'Open folder', - showLocation: 'Show location', - revealedPathLabel: 'Preset files:', - idRequired: 'Give the preset an identifier.', - idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.', - idTaken: 'A preset with this identifier already exists.', - deleteTitle: 'Delete this preset?', - deleteDescription: - 'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.', - deleteConfirm: 'Delete', - deleting: 'Deleting…', + showPicker: 'Choose a mode for new tasks', showPickerBeta: 'Beta', showPickerDescription: - 'When enabled, choose a mode for each new task and set a default here. When disabled, new tasks use the application\'s configured default. Existing tasks are unaffected.', - enablePickerToSetDefault: 'Enable mode selection for new tasks before setting a default', - enablePickerToCreate: 'Enable mode selection for new tasks before starting Creator mode', + 'When enabled, each new task can choose a mode and the default is set here. When disabled, new tasks use the application default preset. Existing tasks are unaffected.', + enablePickerToSetDefault: 'Turn on mode selection for new tasks to choose a default', + enablePickerToCreate: 'Turn on mode selection for new tasks to start Creator mode', } /** Simplified Chinese copy. */ export const zh: Record = { ...guideZh, - error: '无法加载 Agent 预设。', - userTrust: '自定义', + builtInGroup: '内置', customGroup: '自定义', + sectionIntro: '选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。', + authoringHelp: '如何创建或修改预设', + authoringTitle: '创建和修改预设', + authoringBody: '预设由已安装的 bundle 声明,本页只负责选择。要创建或修改预设,请用「创造模式」开始一个任务:Agent 会编写一个声明或覆盖该预设的 bundle,并安装到当前 profile。', + seatHint: '选择新任务使用的 Agent 预设', headerHint: '本任务的 Agent 预设,在任务开始时确定', nav: 'Agent 预设', - sectionIntro: '选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。', - builtIn: '内置', + setDefault: '设为新任务默认', - view: '查看配置', + presetStandardName: '标准模式', presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。', presetPtcName: 'PTC 模式', @@ -115,41 +107,19 @@ export const zh: Record = { presetMinimalDescription: 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。', presetCordisName: '创造模式', presetCordisDescription: '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。', - duplicate: '复制预设', - duplicateUnavailable: '此部署未配置可写的预设目录', - delete: '删除', - presetId: '标识符', - presetIdPlaceholder: 'my-agent', - displayName: '名称', - displayNamePlaceholder: '在模式列表中显示的名称,不填则使用标识符', + inUse: '新任务默认', selectionOffDefault: '应用默认', - builtInGroup: '内置', - customGroup: '自定义', + noDescription: '暂无描述。', brokenBadge: '加载失败', - brokenNoCopy: '预设加载失败,不能复制', + switchRefused: '无法切换到「{name}」:{reason}', - copyOf: '复制自', - composition: '预设配置(agent.cordis.yml)', - cancel: '取消', + close: '关闭', - retry: '重试', - copyTitle: '复制预设', - copyIntro: '复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。', - create: '创建', - creating: '正在创建…', + creatorDraft: '让 Agent 帮我创建预设模式', - openLocation: '打开目录', - showLocation: '查看路径', - revealedPathLabel: '预设文件:', - idRequired: '请填写标识符。', - idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。', - idTaken: '该标识符已被占用。', - deleteTitle: '删除该预设?', - deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。', - deleteConfirm: '删除', - deleting: '正在删除…', + showPicker: '新任务可选择模式', showPickerBeta: 'Beta', showPickerDescription: '开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。', @@ -157,8 +127,8 @@ export const zh: Record = { enablePickerToCreate: '请先开启新任务模式选择,再启动创造模式', } -// The resolution itself is the shared fold in `dsh-agent-presets/display`, +// The resolution itself is the shared fold in `dsh-agent-preset-registry/display`, // re-exported here so every surface in this plugin reads one path; the // Settings plugin list inlines the same fold over this plugin's dictionaries. -export { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display' -export type { PresetDisplaySource, PresetDisplayText } from '@deepseek-ai/dsh-agent-presets/display' +export { isBuiltInPreset, presetDisplayText } from '@deepseek-ai/dsh-agent-preset-registry/display' +export type { PresetDisplaySource, PresetDisplayText } from '@deepseek-ai/dsh-agent-preset-registry/display' diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 45bd9373e4..51c9d297cb 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -15,7 +15,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type {} from '@deepseek-ai/dsh-agent-presets/types' +import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import { presetOptions, readRoster } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 4ac69b8ca7..334a134d87 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -1,437 +1,74 @@ -/** - * Agent-preset management controller: the roster as a list, a copy dialog as - * the only way a preset is created, and a read-only viewer over the shipped - * compositions. - * - * The browser edits no composition text. A new preset is a host-side copy of - * an existing one (`{ from, id, name? }` is all that crosses the wire), and - * everything after creation happens in the preset's own files — which is why - * the page's other job is getting the user TO those files: open the directory - * where the host has a desktop, show its path where it does not. - * - * The host stays the single fact source. Every mutation writes through the - * wire and the page re-reads the roster afterwards, because a copy changes - * more than the row it targeted. - */ - -import type { Context as ClientContext } from '@deepseek-ai/cordis' -// Type-only: pulls the ctx.remote merge into this program. +/** Preset roster and selection policy for the settings section. */ +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import { beginRosterRead, writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts' +import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types' +import { writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts' -/** Ids a preset directory may be named, mirroring the host's own rule. */ -const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ - -const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) - -/** One preset row the page renders. */ -export interface PresetRow { - /** Preset id and directory name; the display name falls back to it. */ - id: string - /** Display name the preset published, absent when it published none. */ - name?: string - /** One sentence on what the preset is for. */ - description?: string - /** Whether the preset ships with the deployment or was authored locally. */ - trust: 'system' | 'user' - /** Whether a session that names no preset gets this one. */ - isDefault: boolean - /** - * Why the preset cannot compose a session, absent when it can. A broken - * row renders marked and unselectable — its directory still occupies the - * id, so deleting it (or fixing the files) is the way out, and this page - * is where both of those live. - */ - broken?: string -} - -/** The copy dialog: a new id and optional display name over a fixed source. */ -export interface CopyDraft { - /** The preset being copied. */ - from: string - /** Display name of the source, for the dialog title. */ - fromTitle: string - /** New preset id being typed; the directory name, so it is required. */ - id: string - /** Display name being typed; empty falls back to the id. */ - name: string - /** Whether the copy is in flight. */ - saving: boolean - /** The last copy failure, cleared by the next edit. */ - error: string | null -} - -/** The read-only composition viewer over one shipped preset. */ -export interface PresetView { - /** The preset whose composition is shown. */ - id: string - /** Display name, for the dialog title. */ - title: string - /** Composition text exactly as stored. */ - content: string -} - -/** Page snapshot. */ +/** Settings page state. */ export interface AgentPresetSectionState { - status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error' - /** Whole-load failure text; a copy failure stays on the dialog. */ + status: 'idle' | 'loading' | 'ready' | 'error' error: string | null - /** Whether the deployment configures a root new presets can be written to. */ - authorable: boolean - /** Whether the host can open a preset directory on a native desktop. */ - hasDocument: boolean - /** Whether new-session surfaces expose preset selection. */ showPicker: boolean - /** Whether a mode-selection policy write is in flight. */ policySaving: boolean - /** Every preset the deployment currently supplies. */ - rows: readonly PresetRow[] - /** The open copy dialog, or null. */ - copy: CopyDraft | null - /** The open read-only viewer, or null. */ - view: PresetView | null - /** The preset awaiting delete confirmation. */ - pendingDelete: string | null - /** Whether a delete is in flight. */ - deleting: boolean - /** - * Preset directories shown as text because the host has no desktop opener - * — the answer `openDocument` gives instead of opening. - */ - revealedPaths: Readonly> + rows: readonly AgentPresetRow[] } +const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [] } +const message = (error: unknown): string => error instanceof Error ? error.message : String(error) -const INITIAL: AgentPresetSectionState = { - status: 'idle', - error: null, - authorable: false, - hasDocument: false, - showPicker: false, - policySaving: false, - rows: [], - copy: null, - view: null, - pendingDelete: null, - deleting: false, - revealedPaths: {}, -} - -/** - * Why this copy cannot be submitted yet, as a locale key, or undefined when - * it can. Client-side only: the host re-checks the id and its answer is what - * the dialog reports on failure. - * @param draft - the open copy dialog. - * @param rows - the roster, for the collision check. - * @returns the blocking reason's locale key, or undefined when submittable. - */ -export function draftBlocker( - draft: CopyDraft, - rows: readonly PresetRow[], -): 'idRequired' | 'idInvalid' | 'idTaken' | undefined { - if (draft.id === '') return 'idRequired' - if (!PRESET_ID.test(draft.id)) return 'idInvalid' - // A copy never overwrites: landing on a name already in use would replace - // something the user did not open. - if (rows.some(row => row.id === draft.id)) return 'idTaken' - return undefined -} - -/** Reads the roster and drives the copy dialog, viewer, and location reveals. */ +/** Loads the roster and writes the default and chooser policy. */ export class AgentPresetSectionController { - /** Page snapshot the renderer subscribes to. */ + /** Observable roster and selection state. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) + private loading: Promise | undefined + constructor(private readonly ctx: Context) {} - /** The one roster load whose completion current callers await. */ - private loadFlight: Promise | undefined - private reloadRequested = false + private set(patch: Partial): void { this.store.set({ ...this.store.getSnapshot(), ...patch }) } - constructor( - private readonly ctx: ClientContext, - /** - * Called after this page changes the roster DIRECTORY, so the other - * surfaces reading the same roster re-read it. A settings field moving is - * already announced by the host through the forwarded - * `settings/document-updated`; a directory copied or deleted here is not, - * and the new-session chip has no other way to learn a preset it should - * offer now exists. - */ - private readonly rosterChanged: () => void = () => {}, - ) {} - - private set(patch: Partial): void { - this.store.set({ ...this.store.getSnapshot(), ...patch }) - } - - /** Read back and reflect the Host-effective default after a policy write. */ - private async confirmEffectiveDefault(showPicker: boolean): Promise { - await this.load() - if (this.store.getSnapshot().status === 'error') await this.load() - const state = this.store.getSnapshot() - if (state.status !== 'ready' || state.showPicker !== showPicker) return undefined - return state.rows.find(row => row.isDefault)?.id - } - - /** - * Show or hide new-session preset selection without changing the saved - * default. The Host roster resolves that saved default while selection is - * shown and the deployment default while it is hidden. - * @param showPicker - whether the new-session picker should be exposed. - * @param syncBlankSession - optional current-blank-task sync kept inside the saving state. - * @returns once the Host state and optional blank-task sync settle. + /** Refresh the roster; concurrent calls share one read. + * @returns Once the roster read settles. */ - async setPickerVisible( - showPicker: boolean, - syncBlankSession?: (id: string) => Promise, - ): Promise { - const state = this.store.getSnapshot() - if (state.status !== 'ready' || state.policySaving || state.showPicker === showPicker) return + load(): Promise { + return this.loading ??= this.readRoster().finally(() => { this.loading = undefined }) + } + private async readRoster(): Promise { + try { + const result = await this.ctx.remote.agentPresets.list() + if (!result.ok) throw new Error(result.error.message) + this.set({ status: 'ready', error: null, rows: result.value.presets, showPicker: result.value.modeSelectionEnabled }) + } catch (error) { this.set({ status: 'error', error: message(error) }) } + } + + /** Set the default and synchronize the current blank task when supplied. + * @param id Selected default. + * @param sync Blank-session synchronization callback. + * @returns Once saved and refreshed. + */ + async makeDefault(id: string, sync?: (id: string) => Promise): Promise { + if (!this.store.getSnapshot().showPicker) return + await this.policy(() => writeDefaultPreset(this.ctx, id), sync) + } + /** Change chooser visibility. + * @param visible Whether the chooser is visible. + * @param sync Blank-session synchronization callback. + * @returns Once saved and refreshed. + */ + async setPickerVisible(visible: boolean, sync?: (id: string) => Promise): Promise { + await this.policy(() => writeModeSelectionEnabled(this.ctx, visible), sync) + } + private async policy(write: () => Promise, sync?: (id: string) => Promise): Promise { + if (this.store.getSnapshot().policySaving) return this.set({ policySaving: true, error: null }) try { - const failure = await writeModeSelectionEnabled(this.ctx, showPicker) - if (failure !== undefined) { - await this.load() - this.set({ error: failure }) - return - } - const effectiveDefault = await this.confirmEffectiveDefault(showPicker) - if (effectiveDefault === undefined) return - const syncFailure = await syncBlankSession?.(effectiveDefault) - if (syncFailure !== undefined) this.set({ error: syncFailure }) - } catch (error: unknown) { + const error = await write() await this.load() - this.set({ error: errorMessage(error) }) - } finally { - this.set({ policySaving: false }) - } - } - - private patchCopy(patch: Partial): void { - const { copy } = this.store.getSnapshot() - if (copy === null) return - this.set({ copy: { ...copy, ...patch } }) - } - - /** - * Load the roster. An empty roster means the deployment composes no - * presets, which is a valid deployment rather than a failure — the section - * reports `unavailable` and renders nothing. - * @returns once the snapshot reflects the host. - */ - async load(): Promise { - this.reloadRequested = true - this.loadFlight ??= this.drainLoads() - await this.loadFlight - } - - /** Coalesce invalidations without losing changes received during a read. */ - private async drainLoads(): Promise { - try { - do { - await this.loadOnce() - } while (this.reloadRequested) - } finally { - this.loadFlight = undefined - } - } - - /** Perform the section's one owned roster read. */ - private async loadOnce(): Promise { - this.reloadRequested = false - // Whether a preset's directory can be opened is the Host's opener - // capability rather than a roster property, so the page joins the two. - // Both reads start together; one missing capability does not hide the roster. - const opener = this.ctx.remote.settings.canOpenAgentPresetDirectory() - const roster = await beginRosterRead(this.ctx, this.store) - // A refused describe leaves the reveal-the-path path, which needs no opener. - const described = await opener - if (roster === undefined) return - const { presets, authorable, modeSelectionEnabled: showPicker } = roster - const hasDocument = described.ok && described.value - if (presets.length === 0) { - // Nothing to manage leaves nothing to keep a dialog open over. - this.set({ - status: 'unavailable', rows: [], authorable, hasDocument, showPicker, copy: null, view: null, - }) - return - } - // A reveal outlives a reload but not its preset: a path for a row the - // roster no longer lists would be a claim about a directory that is gone. - const revealed = this.store.getSnapshot().revealedPaths - const kept = Object.fromEntries( - Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id))) - this.set({ - status: 'ready', - error: null, - authorable, - hasDocument, - showPicker, - rows: presets.map(preset => ({ ...preset })), - revealedPaths: kept, - }) - } - - /** - * Open one shipped preset's composition in the read-only viewer. - * @param id - the preset to view. - * @returns once the composition loaded or the failure is on the page. - */ - async view(id: string): Promise { - this.set({ error: null }) - const result = await this.ctx.remote.agentPresets.read(id) - if (!result.ok) { - this.set({ error: result.error.message }) - return - } - const { name, content } = result.value - this.set({ view: { id, title: name ?? id, content } }) - } - - /** Close the read-only viewer. */ - closeView(): void { - this.set({ view: null }) - } - - /** - * Open the copy dialog over one preset. - * @param from - the preset the copy will start from. - */ - beginCopy(from: string): void { - const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from) - this.set({ - error: null, - copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null }, - }) - } - - /** Close the copy dialog, discarding whatever was typed. */ - cancelCopy(): void { - this.set({ copy: null }) - } - - /** - * Name the preset the copy creates. - * @param id - the id typed into the dialog. - */ - setCopyId(id: string): void { - this.patchCopy({ id, error: null }) - } - - /** - * Name the copy's display name. - * @param name - the display name typed into the dialog. - */ - setCopyName(name: string): void { - this.patchCopy({ name, error: null }) - } - - /** - * Submit the copy, re-read the roster, then take the user to the new - * preset's files — the directory opens where the host has a desktop, and - * its path appears on the new row where it does not. - * @returns once the copy settled and the page reflects it. - */ - async confirmCopy(): Promise { - const draft = this.store.getSnapshot().copy - if (draft === null || draft.saving) return - if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return - this.patchCopy({ saving: true, error: null }) - const name = draft.name.trim() - // Every declared parameter is passed even when optional: the Remote face - // checks arity against the declaration and rejects a short call. An - // empty display name goes as `undefined` — absent rather than empty, so - // the host falls back to the id instead of labelling the row with ''. - const result = await this.ctx.remote.agentPresets.copy( - draft.from, draft.id, name === '' ? undefined : name) - if (!result.ok) { - this.patchCopy({ saving: false, error: result.error.message }) - return - } - this.set({ copy: null }) - await this.load() - this.rosterChanged() - // A preset is its files from here on (the dialog collected nothing - // else), so landing in them is the completion, not a follow-up. - await this.openLocation(draft.id) - } - - /** - * Open one preset's directory on the host desktop, or reveal its path on - * the row where the deployment has no opener to hand it to. - * @param id - the preset whose files the user wants. - * @returns once the host answered and the page reflects it. - */ - async openLocation(id: string): Promise { - const result = await this.ctx.remote.settings.openAgentPresetDirectory(id) - if (!result.ok) { - this.set({ error: result.error.message }) - return - } - if (result.value.opened) return - const { path } = result.value - this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) - } - - /** - * Ask for confirmation before deleting one preset. - * @param id - the preset to delete, or null to dismiss the confirmation. - */ - confirmDelete(id: string | null): void { - if (this.store.getSnapshot().deleting) return - this.set({ pendingDelete: id }) - } - - /** - * Delete the preset awaiting confirmation, then re-read the roster. - * - * A session already composed from it keeps running: its composition was - * mounted at creation and nothing re-reads the file. - * @returns once the delete settled and the page reflects it. - */ - async remove(): Promise { - const { pendingDelete, deleting } = this.store.getSnapshot() - if (pendingDelete === null || deleting) return - this.set({ deleting: true, error: null }) - const result = await this.ctx.remote.agentPresets.deletePreset(pendingDelete) - if (!result.ok) { - this.set({ deleting: false, pendingDelete: null, error: result.error.message }) - return - } - this.set({ deleting: false, pendingDelete: null }) - await this.load() - this.rosterChanged() - } - - /** - * Make one preset the default for sessions created later. Running sessions - * keep the composition they began with, so this never disturbs work. - * @param id - the preset to make default. - * @param syncBlankSession - optional current-blank-task sync kept inside the policy lock. - * @returns once the write and optional blank-task sync settle. - */ - async makeDefault( - id: string, - syncBlankSession?: (id: string) => Promise, - ): Promise { - const state = this.store.getSnapshot() - if (!state.showPicker || state.policySaving) return - this.set({ policySaving: true, error: null }) - try { - const failure = await writeDefaultPreset(this.ctx, id) - if (failure !== undefined) { - this.set({ error: failure }) - return + if (error !== undefined) throw new Error(error) + const selected = this.store.getSnapshot().rows.find(row => row.isDefault) + if (selected !== undefined) { + const error = await sync?.(selected.id) + if (error !== undefined) throw new Error(error) } - const effectiveDefault = await this.confirmEffectiveDefault(true) - if (effectiveDefault === undefined) return - const syncFailure = await syncBlankSession?.(effectiveDefault) - if (syncFailure !== undefined) this.set({ error: syncFailure }) - } catch (error: unknown) { - this.set({ - error: errorMessage(error), - }) - } finally { - this.set({ policySaving: false }) - } + } catch (error) { this.set({ error: message(error) }) } + finally { this.set({ policySaving: false }) } } } diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 6a43ad3b22..b043024275 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -10,7 +10,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the ctx.remote merge into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' +import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-preset-registry/types' /** The agent-preset settings namespace on the host wire. */ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' @@ -58,8 +58,6 @@ export function writeModeSelectionEnabled( export interface AgentPresetOption { /** Preset id, written to Settings and the label's fallback. */ id: string - /** Whether the preset ships with the deployment or was authored locally. */ - trust: 'system' | 'user' /** Display name the preset published, absent when it published none. */ name?: string /** One sentence on what the preset is for. */ @@ -72,7 +70,7 @@ export type RosterPreset = AgentPresetRoster['presets'][number] /** The roster, or the message to show in its place. */ export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; error: string } -const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false, modeSelectionEnabled: false } +const EMPTY_ROSTER: AgentPresetRoster = { presets: [], modeSelectionEnabled: false } /** * Read the roster, turning a refusal into the message every surface shows. @@ -129,11 +127,10 @@ export async function beginRosterRead preset.broken === undefined).map(preset => ({ id: preset.id, - trust: preset.trust, ...preset.name === undefined ? {} : { name: preset.name }, ...preset.description === undefined ? {} : { description: preset.description }, })) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 1e28cb44f4..febb0a5f1a 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -30,8 +30,7 @@ import { apply as hostApply } from '../src/index.ts' const ROSTER_ONE = { ok: true as const, value: { - presets: [{ id: 'standard', trust: 'system', isDefault: true }], - authorable: true, + presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: true, }, } @@ -41,10 +40,9 @@ const ROSTER_AUTHORED = { ok: true as const, value: { presets: [ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'mine', trust: 'user', isDefault: false }, + { id: 'standard', isDefault: true }, + { id: 'mine', isDefault: false }, ], - authorable: true, modeSelectionEnabled: true, }, } @@ -54,10 +52,9 @@ const ROSTER_MOVED = { ok: true as const, value: { presets: [ - { id: 'standard', trust: 'system', isDefault: false }, - { id: 'minimal', trust: 'system', isDefault: true }, + { id: 'standard', isDefault: false }, + { id: 'minimal', isDefault: true }, ], - authorable: true, modeSelectionEnabled: true, }, } @@ -66,8 +63,7 @@ const ROSTER_MOVED = { const ROSTER_HIDDEN = { ok: true as const, value: { - presets: [{ id: 'standard', trust: 'system', isDefault: true }], - authorable: true, + presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: false, }, } @@ -94,7 +90,6 @@ async function bench(options: { // The row reads `describe` to learn whether this browser may write at all, // and its default write is the one op this spec records. const settings = { - canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }), describe: () => Promise.resolve({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, @@ -119,10 +114,7 @@ async function bench(options: { settingsSaved = true return Promise.resolve({ ok: true as const, value: {} }) }, - openAgentPresetDirectory: (agentPreset: string) => { - calls.push(`openAgentPresetDirectory:${agentPreset}`) - return Promise.resolve({ ok: true as const, value: { opened: true as const } }) - }, + } const remote = new TestRemote(ctx, { settings }) // The roster and the switch are the AgentPresets Remote namespace; the @@ -141,16 +133,15 @@ async function bench(options: { }, read: () => Promise.resolve({ ok: true as const, - value: { agentPreset: 'standard', trust: 'system', content: '' }, + value: { agentPreset: 'standard', content: '', revision: 'rev' }, }), - copy: (_from: string, id: string) => { - calls.push(`copy:${id}`) + save: (id: string) => { + calls.push(`save:${id}`) // The host's roster now contains it, which is the whole point of the - // copy and what every surface must converge on. + // save and what every surface must converge on. ROSTER = ROSTER_AUTHORED - return Promise.resolve({ ok: true as const, value: undefined }) + return Promise.resolve({ ok: true as const, value: { saved: true } }) }, - deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }), select: (_agentId: SessionId, agentPreset: string) => { calls.push(`select:${agentPreset}`) return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset })) @@ -358,34 +349,7 @@ describe('ui-agent-preset apply', () => { await section.load() await section.makeDefault('standard') expect(section.hooks.agentPresetSection.getSnapshot().rows) - .toEqual([{ id: 'standard', trust: 'system', isDefault: true }]) - }) - - it('routes the section actions to one controller', async () => { - const { ctx, slots, calls } = await bench() - ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() - - await section.load() - section.beginCopy('standard') - section.cancelCopy() - section.beginCopy('standard') - section.setCopyId('mine') - section.setCopyName('我的模式') - await section.confirmCopy() - await section.view('standard') - section.closeView() - section.confirmDelete('mine') - await Promise.all([section.openLocation('mine'), section.remove()]) - - // One controller behind every action: the copy the dialog named is the - // one the roster re-read reflects, and the delete the section confirmed - // is the one its remove() sees. - expect(calls).toContain('copy:mine') - expect(calls.filter(call => call === 'openAgentPresetDirectory:mine').length).toBeGreaterThan(0) - expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2) + .toEqual([{ id: 'standard', isDefault: true }]) }) it('refreshes a showing surface when its namespace changes, and ignores others', async () => { @@ -528,14 +492,6 @@ describe('ui-agent-preset apply', () => { remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(beforeRefresh + 2) }) - const section = (slots.entries('settings.section')[0]! - .inject as unknown as () => AgentPresetSectionInjected)() - await section.load() - section.beginCopy('standard') - section.setCopyId('mine') - section.setCopyName('Mine') - await section.confirmCopy() - delete state.current sessions.notify() await first.load() @@ -685,36 +641,6 @@ describe('ui-agent-preset apply', () => { }) }) - it('offers a just-authored preset on the new-session chip', async () => { - const { ctx, slots } = await bench() - declareRoot(slots) - const conversation = declareConversation(slots) - ctx.provide('conversation', {} as never) - ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never) - ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() - - const chip = slots.entries('conversation.hero.agentPreset')[0]! - const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() - await seat.load() - expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard']) - - const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() - await section.load() - section.beginCopy('standard') - section.setCopyId('mine') - section.setCopyName('我的模式') - await section.confirmCopy() - - // Authoring copies a directory rather than writing a setting, so nothing - // on the wire announces it: a preset created to be used must appear on - // the one screen that starts sessions, without a reload. - await vi.waitFor(() => { - expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine']) - }) - conversation() - }) - it('applies the staged choice to the blank session the flow lands on', async () => { const { ctx, slots, calls } = await bench() declareRoot(slots) @@ -819,7 +745,7 @@ describe('ui-agent-preset apply', () => { await label.load() - expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) + expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard' }]) }) it('stages the creator preset and starts a session from the section', async () => { diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 1ad9a8f035..a900483d4c 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -25,15 +25,15 @@ afterEach(cleanup) const ROSTER_READY: AgentPresetSettingsState = { status: 'ready', error: null, - options: [{ id: 'standard', trust: 'system', name: '标准模式' }, { id: 'mine', trust: 'user' }], + options: [{ id: 'standard' }, { id: 'mine' }], } const SEAT_READY: AgentPresetSeatState = { showPicker: true, current: 'standard', options: [ - { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, - { id: 'mine', trust: 'user' }, + { id: 'standard' }, + { id: 'mine' }, ], busy: false, error: null, @@ -253,7 +253,7 @@ describe('the chip introduce cue', () => { vi.useFakeTimers() const actions = renderSeat({ current: 'creator', - options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }], + options: [{ id: 'creator', name: 'CreatorMode' }], introduce: true, }) @@ -279,7 +279,7 @@ describe('the chip introduce cue', () => { vi.useFakeTimers() renderSeat({ current: 'creator', - options: [{ id: 'creator', trust: 'user', name: '创造模式' }], + options: [{ id: 'creator', name: '创造模式' }], introduce: true, }) @@ -295,7 +295,7 @@ describe('the chip introduce cue', () => { vi.useFakeTimers() const actions = renderSeat({ current: 'creator', - options: [{ id: 'creator', trust: 'user', name: 'C' }], + options: [{ id: 'creator', name: 'C' }], introduce: true, }) @@ -316,7 +316,7 @@ describe('the chip introduce cue', () => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) const actions = renderSeat({ current: 'creator', - options: [{ id: 'creator', trust: 'user', name: '' }], + options: [{ id: 'creator', name: '' }], introduce: true, }) diff --git a/packages/client/ui-agent-preset/tests/locales.client.spec.ts b/packages/client/ui-agent-preset/tests/locales.client.spec.ts index 1d8b40cc67..8c643accfc 100644 --- a/packages/client/ui-agent-preset/tests/locales.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/locales.client.spec.ts @@ -1,4 +1,4 @@ -/** Web-localized copy for the four shipped presets and file copy for every other row. */ +/** Web-localized copy for the four shipped presets and declared copy for every other row. */ import { describe, expect, it } from 'vitest' import { en, presetDisplayText, zh } from '../src/client/locales.ts' @@ -12,7 +12,7 @@ describe('preset display copy', () => { ['minimal', 'presetMinimalName', 'presetMinimalDescription'], ['cordis', 'presetCordisName', 'presetCordisDescription'], ] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => { - const preset = { id, trust: 'system' as const, name: 'file name', description: 'file description' } + const preset = { id } expect(presetDisplayText(preset, translate(en))) .toEqual({ name: en[nameKey], description: en[descriptionKey] }) @@ -20,14 +20,14 @@ describe('preset display copy', () => { .toEqual({ name: zh[nameKey], description: zh[descriptionKey] }) }) - it('keeps file metadata for user and unknown system presets', () => { + it('keeps declared metadata untranslated for named and unknown presets', () => { const fileCopy = { name: '我的标准', description: '团队自己的 preset。' } - expect(presetDisplayText({ id: 'standard', trust: 'user', ...fileCopy }, translate(en))) + expect(presetDisplayText({ id: 'standard', ...fileCopy }, translate(en))) .toEqual(fileCopy) - expect(presetDisplayText({ id: 'deployment-extra', trust: 'system', ...fileCopy }, translate(en))) + expect(presetDisplayText({ id: 'deployment-extra', ...fileCopy }, translate(en))) .toEqual(fileCopy) - expect(presetDisplayText({ id: 'bare', trust: 'user' }, translate(en))) + expect(presetDisplayText({ id: 'bare' }, translate(en))) .toEqual({ name: 'bare' }) }) }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 2dda7bce4d..b3fdba9b98 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -1,721 +1,59 @@ -/** - * The agent-preset management controller: a copy dialog is the only way a - * preset is created, the shipped compositions open in a read-only viewer, and - * the way into a custom preset's files is the location action — opened on a - * desktop, revealed as a path where the host has none. Every mutation - * re-reads the roster because a copy changes more than the row it targeted. - */ +import { describe, expect, it, vi } from 'vitest' +import { AgentPresetSectionController } from '../src/client/section-store.ts' -import { describe, expect, it } from 'vitest' -import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' -import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' - -interface FakePreset { trust: 'system' | 'user'; content: string; name?: string } -interface Recorded { method: string; payload: unknown } - -interface FakeOptions { - /** Every call the controller made, in order. */ - calls?: Recorded[] - /** Reject `list` with this message. */ - failList?: string - /** Reject only this numbered `list` call. */ - failListAt?: number - /** Reject `read` with this message. */ - failRead?: string - /** Reject `copy` with this message. */ - failCopy?: string - /** Reject `openDocument` with this message. */ - failOpen?: string - /** Reject `remove` with this message. */ - failRemove?: string - /** Reject `settings.update` with this message. */ - failSettings?: string - /** Whether the deployment configures a writable root. */ - authorable?: boolean - /** Whether the host can open a preset directory on a desktop. */ - hasDocument?: boolean - /** Refuse the opener capability read. */ - failCapability?: string - /** Hold `remove` until this resolves, to observe the in-flight state. */ - holdRemove?: Promise - /** Initial new-session picker visibility. */ - showPicker?: boolean - /** Mutable Host policy used when a Settings write is reflected by the roster. */ - pickerPolicy?: { enabled: boolean } - /** Simulate a concurrent Host write winning after this client's policy write. */ - ignorePickerWrite?: boolean - /** Throw from the Settings transport instead of returning a Remote failure. */ - throwSettings?: unknown +function fixture() { + const remote = { agentPresets: { + list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: true } })), + }, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } } + const controller = new AgentPresetSectionController({ remote } as never) + return { remote, controller, state: () => controller.store.getSnapshot() } } -const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) -const remoteFail = (message: string) => - Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', message, {}) }) - -/** - * The Remote namespace over an in-memory preset store: copies land, so the - * roster the controller re-reads after a copy is the one the copy produced. - * @param presets - the starting compositions by id. - * @param defaultId - the preset a session with no choice gets. - * @param options - failure injection and call recording. - * @returns the fake plugin context carrying the Remote namespaces. - */ -function fakeCtx( - presets: Map, - defaultId: { id: string }, - options: FakeOptions = {}, -): ClientContext { - const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } - let listCount = 0 - return { - remote: { - agentPresets: { - list: () => { - record('list', {}) - listCount += 1 - if (options.failList !== undefined - && (options.failListAt === undefined || options.failListAt === listCount)) { - return remoteFail(options.failList) - } - const selectionEnabled = options.pickerPolicy?.enabled ?? options.showPicker ?? true - const effectiveDefault = selectionEnabled ? defaultId.id : 'standard' - return remoteOk({ - presets: [...presets].map(([id, preset]) => ({ - id, trust: preset.trust, isDefault: id === effectiveDefault, - ...preset.name === undefined ? {} : { name: preset.name }, - })), - authorable: options.authorable ?? true, - modeSelectionEnabled: selectionEnabled, - }) - }, - read: (agentPreset: string) => { - record('read', { agentPreset }) - if (options.failRead !== undefined) return remoteFail(options.failRead) - const preset = presets.get(agentPreset) - /* v8 ignore next -- every test reads an id the fake store holds */ - if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`) - return remoteOk({ - agentPreset, - trust: preset.trust, - content: preset.content, - ...preset.name === undefined ? {} : { name: preset.name }, - }) - }, - // Arity is checked against the declaration, not against which arguments - // carry a value, so a short call rejects instead of answering. Reject - // one here too: the real face would, and a lenient double hid it once. - copy: (...args: [from: string, id: string, name?: string]) => { - if (args.length !== 3) { - return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`)) - } - const [from, id, name] = args - record('copy', { from, id, ...name === undefined ? {} : { name } }) - if (options.failCopy !== undefined) return remoteFail(options.failCopy) - const source = presets.get(from) - /* v8 ignore next -- every test copies a source the fake store holds */ - if (source === undefined) return remoteFail(`unknown preset ${from}`) - presets.set(id, { - trust: 'user', - content: source.content, - ...name === undefined ? {} : { name }, - }) - return remoteOk(undefined) - }, - deletePreset: async (id: string) => { - record('deletePreset', { id }) - await options.holdRemove - if (options.failRemove !== undefined) return await remoteFail(options.failRemove) - presets.delete(id) - return await remoteOk(undefined) - }, - }, - settings: { - update: (ns: string, patch: { default?: unknown; modeSelectionEnabled?: unknown }) => { - record('settings.update', { ns, patch }) - if (options.throwSettings !== undefined) throw options.throwSettings - if (options.failSettings !== undefined) return remoteFail(options.failSettings) - if (typeof patch.default === 'string') defaultId.id = patch.default - if (typeof patch.modeSelectionEnabled === 'boolean' && !options.ignorePickerWrite) { - const policy = options.pickerPolicy ?? { enabled: options.showPicker ?? true } - policy.enabled = patch.modeSelectionEnabled - } - return remoteOk({}) - }, - canOpenAgentPresetDirectory: () => { - record('canOpenAgentPresetDirectory', {}) - return options.failCapability === undefined - ? remoteOk(options.hasDocument ?? true) - : remoteFail(options.failCapability) - }, - openAgentPresetDirectory: (agentPreset: string) => { - record('openAgentPresetDirectory', { agentPreset }) - if (options.failOpen !== undefined) return remoteFail(options.failOpen) - return (options.hasDocument ?? true) - ? remoteOk({ opened: true }) - : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) - }, - }, - }, - } as unknown as ClientContext -} - -function seed(): Map { - return new Map([ - ['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }], - ['mine', { trust: 'user', content: '- id: tool-read\n' }], - ]) -} - -function harness(options: FakeOptions = {}) { - const presets = seed() - const defaultId = { id: 'standard' } - const calls: Recorded[] = [] - let rosterChanges = 0 - const pickerPolicy = options.pickerPolicy ?? { enabled: options.showPicker ?? true } - const wired = { ...options, pickerPolicy, calls: options.calls ?? calls } - const controller = new AgentPresetSectionController( - fakeCtx(presets, defaultId, wired), - () => { rosterChanges += 1 }, - ) - return { - controller, presets, defaultId, pickerPolicy, calls, - rosterChanges: () => rosterChanges, - } -} - -function copyOf(controller: AgentPresetSectionController): CopyDraft { - const { copy } = controller.store.getSnapshot() - if (copy === null) throw new Error('expected an open copy dialog') - return copy -} - -describe('loading the roster', () => { - it('still lists the roster when the opener capability is refused', async () => { - const { controller } = harness({ failCapability: 'no opener here' }) - +describe('the preset roster', () => { + it('reads the roster once for simultaneous loads and surfaces failed reads', async () => { + const { controller, remote, state } = fixture() + await Promise.all([controller.load(), controller.load()]) + expect(remote.agentPresets.list).toHaveBeenCalledOnce() + expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true }) + remote.agentPresets.list.mockRejectedValueOnce(new Error('offline')) await controller.load() - - // The two reads are independent: a refused capability query costs the - // open-directory affordance, not the page. - const state = controller.store.getSnapshot() - expect(state.status).toBe('ready') - expect(state.hasDocument).toBe(false) - expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine']) - }) - - it('maps the roster onto rows with the capability flags', async () => { - const { controller } = harness({ authorable: true, hasDocument: false }) - + expect(state()).toMatchObject({ status: 'error', error: 'offline' }) + remote.agentPresets.list.mockResolvedValueOnce({ ok: false, error: { message: 'refused' } } as never) await controller.load() - - const state = controller.store.getSnapshot() - expect(state.status).toBe('ready') - expect(state.authorable).toBe(true) - expect(state.hasDocument).toBe(false) - expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine']) - expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' }) - }) - - it('reports an empty roster as unavailable, not as an error', async () => { - const { controller, presets } = harness() - presets.clear() - + expect(state().error).toBe('refused') + remote.agentPresets.list.mockRejectedValueOnce('gone') await controller.load() - - expect(controller.store.getSnapshot().status).toBe('unavailable') + expect(state().error).toBe('gone') }) - it('coalesces concurrent refreshes into one follow-up read', async () => { - const { controller, calls } = harness() - - await Promise.all([controller.load(), controller.load(), controller.load()]) - - expect(calls.filter(call => call.method === 'list')).toHaveLength(2) - }) - - it('surfaces a refusal as the page error', async () => { - const { controller } = harness({ failList: 'not for you' }) - - await controller.load() - - const state = controller.store.getSnapshot() - expect(state.status).toBe('error') - expect(state.error).toBe('not for you') - }) - -}) - -describe('the read-only viewer', () => { - it('opens a shipped composition under its display name', async () => { - const { controller } = harness() - await controller.load() - - await controller.view('standard') - - expect(controller.store.getSnapshot().view).toEqual({ - id: 'standard', title: '标准模式', content: '- id: tool-bash\n', - }) - }) - - it('falls back to the id when the preset published no name', async () => { - const { controller } = harness() - await controller.load() - - await controller.view('mine') - - expect(controller.store.getSnapshot().view?.title).toBe('mine') - }) - - it('closes without touching the list', async () => { - const { controller } = harness() - await controller.load() - await controller.view('standard') - - controller.closeView() - - expect(controller.store.getSnapshot().view).toBeNull() - expect(controller.store.getSnapshot().rows).toHaveLength(2) - }) - - it('puts a read refusal on the page rather than opening empty', async () => { - const { controller } = harness({ failRead: 'no peeking' }) - await controller.load() - - await controller.view('standard') - - expect(controller.store.getSnapshot().view).toBeNull() - expect(controller.store.getSnapshot().error).toBe('no peeking') - }) - -}) - -describe('the copy dialog', () => { - it('opens over the source with its display name in the title', async () => { - const { controller } = harness() - await controller.load() - - controller.beginCopy('standard') - - expect(copyOf(controller)).toMatchObject({ - from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, - }) - }) - - it('falls back to the source id when it published no name', async () => { - const { controller } = harness() - await controller.load() - - controller.beginCopy('mine') - - expect(copyOf(controller).fromTitle).toBe('mine') - }) - - it('cancel discards whatever was typed', async () => { - const { controller } = harness() - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('half-typed') - - controller.cancelCopy() - - expect(controller.store.getSnapshot().copy).toBeNull() - }) - - it('ignores field edits and submits with no dialog open', async () => { - const { controller, calls } = harness() - await controller.load() - - controller.setCopyId('typed-into-nothing') - controller.setCopyName('nameless') - await controller.confirmCopy() - - expect(controller.store.getSnapshot().copy).toBeNull() - expect(calls.some(call => call.method === 'copy')).toBe(false) - }) - - it('typing clears the previous failure', async () => { - const { controller } = harness({ failCopy: 'disk full' }) - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - await controller.confirmCopy() - expect(copyOf(controller).error).toBe('disk full') - - controller.setCopyName('renamed') - - expect(copyOf(controller).error).toBeNull() - }) -}) - -describe('the copy blocker', () => { - const rows: PresetRow[] = [ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'mine', trust: 'user', isDefault: false }, - ] - const draft = (id: string): CopyDraft => - ({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null }) - - it('requires an id, a containable shape, and a free name', () => { - expect(draftBlocker(draft(''), rows)).toBe('idRequired') - expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid') - expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid') - expect(draftBlocker(draft('mine'), rows)).toBe('idTaken') - expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined() - }) -}) - -describe('submitting a copy', () => { - it('copies, re-reads the roster, announces the change, and opens the files', async () => { - const { controller, calls, rosterChanges } = harness() - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - controller.setCopyName('我的模式') - - await controller.confirmCopy() - - const state = controller.store.getSnapshot() - expect(state.copy).toBeNull() - expect(state.rows.map(row => row.id)).toContain('my-copy') - expect(rosterChanges()).toBe(1) - expect(calls.find(call => call.method === 'copy')?.payload) - .toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' }) - // A preset is its files from here on, so landing in them completes the - // copy rather than following it. - expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload) - .toEqual({ agentPreset: 'my-copy' }) - }) - - it('omits an empty name so the copy falls back to its id', async () => { - const { controller, calls } = harness() - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - controller.setCopyName(' ') - - await controller.confirmCopy() - - expect(calls.find(call => call.method === 'copy')?.payload) - .toEqual({ from: 'standard', id: 'my-copy' }) - }) - - it('reveals the new directory as text where the host has no desktop', async () => { - const { controller } = harness({ hasDocument: false }) - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - - await controller.confirmCopy() - - expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy') - }) - - it('keeps the dialog open with the refusal on it', async () => { - const { controller, rosterChanges } = harness({ failCopy: 'id already exists' }) - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - - await controller.confirmCopy() - - expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' }) - expect(rosterChanges()).toBe(0) - }) - - it('refuses to submit while blocked or already saving', async () => { - const { controller, calls } = harness() - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('mine') - - await controller.confirmCopy() - - expect(calls.some(call => call.method === 'copy')).toBe(false) - }) -}) - -describe('the location action', () => { - it('opens the directory and leaves the page alone on a desktop host', async () => { - const { controller, calls } = harness() - await controller.load() - - await controller.openLocation('mine') - - expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload).toEqual({ agentPreset: 'mine' }) - expect(controller.store.getSnapshot().revealedPaths).toEqual({}) - }) - - it('reveals the path on the row where the host has none', async () => { - const { controller } = harness({ hasDocument: false }) - await controller.load() - - await controller.openLocation('mine') - - expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' }) - }) - - it('drops a revealed path once its preset leaves the roster', async () => { - const { controller, presets } = harness({ hasDocument: false }) - await controller.load() - await controller.openLocation('mine') - presets.delete('mine') - - await controller.load() - - expect(controller.store.getSnapshot().revealedPaths).toEqual({}) - }) - - it('surfaces a refusal as the page error', async () => { - const { controller } = harness({ failOpen: 'not yours' }) - await controller.load() - - await controller.openLocation('mine') - - expect(controller.store.getSnapshot().error).toBe('not yours') - }) - -}) - -describe('deleting', () => { - it('asks first, then deletes, re-reads, and announces the change', async () => { - const { controller, rosterChanges } = harness() - await controller.load() - - controller.confirmDelete('mine') - expect(controller.store.getSnapshot().pendingDelete).toBe('mine') - await controller.remove() - - const state = controller.store.getSnapshot() - expect(state.pendingDelete).toBeNull() - expect(state.rows.map(row => row.id)).not.toContain('mine') - expect(rosterChanges()).toBe(1) - }) - - it('dismisses the confirmation without deleting', async () => { - const { controller, calls } = harness() - await controller.load() - controller.confirmDelete('mine') - - controller.confirmDelete(null) - await controller.remove() - - expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine') - expect(calls.some(call => call.method === 'deletePreset')).toBe(false) - }) - - it('ignores a second confirmation while one delete is in flight', async () => { - let release = (): void => {} - const gate = new Promise((resolve) => { release = resolve }) - const { controller, calls } = harness({ holdRemove: gate }) - await controller.load() - controller.confirmDelete('mine') - const removal = controller.remove() - - controller.confirmDelete('standard') - await controller.remove() - release() - await removal - - expect(calls.filter(call => call.method === 'deletePreset')).toHaveLength(1) - }) - - it('surfaces a refusal and clears the confirmation', async () => { - const { controller } = harness({ failRemove: 'shipped preset' }) - await controller.load() - controller.confirmDelete('mine') - - await controller.remove() - - const state = controller.store.getSnapshot() - expect(state.error).toBe('shipped preset') - expect(state.pendingDelete).toBeNull() - expect(state.deleting).toBe(false) - }) - -}) - -describe('a controller with no roster listener', () => { - it('completes a delete without anyone to notify', async () => { - // The rosterChanged callback is optional wiring, not a requirement: a - // page composed without sibling surfaces still deletes cleanly. - const presets = seed() - const defaultId = { id: 'standard' } - const alone = new AgentPresetSectionController( - fakeCtx(presets, defaultId)) - await alone.load() - alone.confirmDelete('mine') - - await alone.remove() - - expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine') - }) -}) - -describe('the default preset', () => { - it('syncs the Host value that wins before the confirming read', async () => { - const { controller, defaultId } = harness() - const synced: string[] = [] - const sync = (id: string): Promise => { - synced.push(id) - return Promise.resolve(undefined) - } - await controller.load() - - const makingDefault = controller.makeDefault('mine', sync) - defaultId.id = 'standard' - await makingDefault - - expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard') - expect(synced).toEqual(['standard']) - - const missingDefault = controller.makeDefault('mine', sync) - defaultId.id = 'missing' - await missingDefault - expect(synced).toEqual(['standard']) - }) - - it('surfaces a settings refusal as the page error', async () => { - const { controller } = harness({ failSettings: 'read-only settings' }) - await controller.load() - - await controller.makeDefault('mine') - - expect(controller.store.getSnapshot().error).toContain('read-only settings') - }) - - it('keeps a composition sync failure on the page', async () => { - const { controller } = harness() - await controller.load() - - await controller.makeDefault( - 'mine', - () => Promise.resolve('blank session rejected the preset'), - ) - - expect(controller.store.getSnapshot()).toMatchObject({ - error: 'blank session rejected the preset', policySaving: false, - }) - }) - - it('restores the policy lock after a thrown default write', async () => { - const { controller } = harness({ throwSettings: 'settings transport unavailable' }) - await controller.load() - - await controller.makeDefault('mine') - - expect(controller.store.getSnapshot()).toMatchObject({ - error: 'settings transport unavailable', policySaving: false, - }) - }) - -}) - -describe('the new-session picker preference', () => { - it('ignores policy writes until the section is ready', async () => { - const { controller, calls } = harness({ showPicker: true }) - + it('keeps default selection and blank-session synchronization on their existing settings path', async () => { + const { controller, remote, state } = fixture() + const sync = vi.fn(async () => undefined) + await controller.makeDefault('standard', sync) + expect(remote.settings.update).toHaveBeenCalledWith('agent-presets', { default: 'standard' }, undefined) + expect(sync).toHaveBeenCalledWith('standard') await controller.setPickerVisible(false) - - expect(calls.some(call => call.method === 'settings.update')).toBe(false) - }) - - it('uses Standard while disabled and restores the saved default when re-enabled', async () => { - const { controller, calls, defaultId } = harness({ - showPicker: true, failList: 'connection moved', failListAt: 2, - }) - const synced: string[] = [] - const sync = (id: string): Promise => { - synced.push(id) - return Promise.resolve(undefined) - } - defaultId.id = 'mine' - await controller.load() - expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine') - - await controller.setPickerVisible(false, sync) - expect(calls.filter(call => call.method === 'settings.update')[0]?.payload) - .toEqual({ ns: 'agent-presets', patch: { modeSelectionEnabled: false } }) - - expect(controller.store.getSnapshot()).toMatchObject({ - showPicker: false, policySaving: false, - }) - expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard') - - await controller.setPickerVisible(true, sync) - expect(calls.filter(call => call.method === 'settings.update')[1]?.payload) - .toEqual({ ns: 'agent-presets', patch: { modeSelectionEnabled: true } }) - expect(controller.store.getSnapshot()).toMatchObject({ - showPicker: true, policySaving: false, - }) - expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine') - expect(synced).toEqual(['standard', 'mine']) - }) - - it('reloads Host truth and reports a refused visibility write', async () => { - const { controller } = harness({ showPicker: true, failSettings: 'read-only settings' }) - await controller.load() - - await controller.setPickerVisible(false) - - expect(controller.store.getSnapshot()).toMatchObject({ - showPicker: true, error: 'read-only settings', policySaving: false, - }) - }) - - it('reloads Host truth when another policy value wins the write', async () => { - const { controller } = harness({ showPicker: true, ignorePickerWrite: true }) - await controller.load() - - await controller.setPickerVisible(false) - - expect(controller.store.getSnapshot()).toMatchObject({ showPicker: true, policySaving: false }) - }) - - it('reloads a roster that cannot mark an effective default', async () => { - const { controller, defaultId } = harness({ showPicker: false }) - defaultId.id = 'missing' - await controller.load() - + expect(remote.settings.update).toHaveBeenLastCalledWith('agent-presets', { modeSelectionEnabled: false }, undefined) + remote.settings.update.mockRejectedValueOnce(new Error('read only')) await controller.setPickerVisible(true) - - expect(controller.store.getSnapshot()).toMatchObject({ showPicker: true, policySaving: false }) - expect(controller.store.getSnapshot().rows.every(row => !row.isDefault)).toBe(true) + expect(state()).toMatchObject({ policySaving: false, error: 'read only' }) }) - it('keeps a blank-task sync failure on the page', async () => { - const { controller } = harness({ showPicker: true }) - await controller.load() - - await controller.setPickerVisible( - false, - () => Promise.resolve('blank session rejected the policy'), - ) - - expect(controller.store.getSnapshot()).toMatchObject({ - error: 'blank session rejected the policy', policySaving: false, - }) - }) - - it('reloads Host truth after a thrown visibility write', async () => { - const { controller } = harness({ showPicker: true, throwSettings: new Error('connection lost') }) - await controller.load() - + it('prevents a second policy write and reports blank-session synchronization failures', async () => { + const { controller, remote, state } = fixture() + let release!: () => void + const wait = new Promise((resolve) => { release = resolve }) + remote.settings.update.mockImplementationOnce(async () => { await wait; return { ok: true, value: {} } }) + const pending = controller.makeDefault('standard', async () => 'Session already started') await controller.setPickerVisible(false) - - expect(controller.store.getSnapshot()).toMatchObject({ - showPicker: true, error: 'connection lost', policySaving: false, - }) + expect(remote.settings.update).toHaveBeenCalledOnce() + release() + await pending + expect(state().error).toBe('Session already started') + remote.agentPresets.list.mockResolvedValueOnce({ ok: true, value: { presets: [], modeSelectionEnabled: false } }) + await controller.setPickerVisible(false) + const writes = remote.settings.update.mock.calls.length + await controller.makeDefault('standard') + expect(remote.settings.update).toHaveBeenCalledTimes(writes) }) - - it('ignores default writes while mode selection is disabled', async () => { - const { controller, calls } = harness({ showPicker: false }) - await controller.load() - - await controller.makeDefault('mine') - - expect(calls.some(call => call.method === 'settings.update')).toBe(false) - }) - }) diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 4bf77e08bb..294b54b7e9 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -1,687 +1,213 @@ // @vitest-environment jsdom -/** - * The management section's rendering rules: which actions a row offers depends - * on its trust, a shipped composition opens in a read-only viewer, creation is - * a copy dialog that collects an id and an optional name, and the location - * action follows the host's desktop capability. - */ - import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' -import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' -import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts' +import { AgentPresetSection, type AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionState } from '../src/client/section-store.ts' import { en } from '../src/client/locales.ts' - -afterEach(cleanup) - +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers() }) const translations: ReadonlyMap = new Map(Object.entries(en)) - -const READY: AgentPresetSectionState = { - status: 'ready', - error: null, - authorable: true, - hasDocument: true, - showPicker: true, - policySaving: false, - rows: [ - { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, - { id: 'mine', trust: 'user', isDefault: false }, - ], - copy: null, - view: null, - pendingDelete: null, - deleting: false, - revealedPaths: {}, -} - function unusedHook(): never { throw new Error('This section does not read global slot sources') } - -/** - * Render the section over a fixed snapshot, with every action a spy. - * @param state - the snapshot to render. - * @returns the spies, so a test can assert what a click reached. - */ -function renderSection( - state: Partial = {}, - options: { creator?: boolean; developerTools?: boolean } = {}, -) { - const store = createSnapshotStore({ ...READY, ...state }) - const actions = { - load: vi.fn(() => Promise.resolve()), - // The shell-owned section affordance (SettingsSectionOwnerProps.close). - close: vi.fn(), - ...options.creator === false ? {} : { startCreatorDraft: vi.fn() }, - view: vi.fn(() => Promise.resolve()), - closeView: vi.fn(), - beginCopy: vi.fn(), - cancelCopy: vi.fn(), - setCopyId: vi.fn(), - setCopyName: vi.fn(), - confirmCopy: vi.fn(() => Promise.resolve()), - openLocation: vi.fn(() => Promise.resolve()), - confirmDelete: vi.fn(), - remove: vi.fn(() => Promise.resolve()), - makeDefault: vi.fn(() => Promise.resolve()), - setPickerVisible: vi.fn(() => Promise.resolve()), - } - const props: AgentPresetSectionProps = { - ...actions, - usePanelInfo: unusedHook, - useSessions: unusedHook, - useSessionStatus: unusedHook, - useSessionRetainInfo: unusedHook, - useWorkspaces: unusedHook, - useResource: unusedHook, +function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true) { + const store = createSnapshotStore({ status: 'ready', error: null, + showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], ...partial }) + const actions = { load: vi.fn(async () => {}), makeDefault: vi.fn(async () => {}), + setPickerVisible: vi.fn(async () => {}), close: vi.fn() } + const props: AgentPresetSectionProps = { ...actions, + ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), + usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, + useWorkspaces: unusedHook, useResource: unusedHook, useAgentPresetSection: bindSnapshotSelector(store), - useDeveloperTools: bindSnapshotSelector(createSnapshotStore(options.developerTools ?? true)), - t: key => translations.get(key) ?? key, - } + useDeveloperTools: bindSnapshotSelector(createSnapshotStore(developerTools)), + t: key => translations.get(key) ?? key } render() return actions } - -/** Locate a card by the id it prints, not by its display name. */ function rowFor(id: string): HTMLElement { - const key = screen.getAllByText(id).find(node => node.tagName === 'CODE') - const row = key?.closest('li') ?? null - /* v8 ignore next -- every rendered card prints its id */ + const row = document.querySelector(`[data-agent-preset-id="${id}"]`) if (row === null) throw new Error(`no card for ${id}`) return row } - -describe('the preset list', () => { - it('hides the complete picker-policy row while developer tools are off', () => { - renderSection({}, { developerTools: false }) - expect(screen.queryByRole('switch', { name: en.showPicker })).toBeNull() - expect(screen.queryByText(en.showPickerDescription)).toBeNull() - expect(screen.queryByText(en.showPickerBeta)).toBeNull() - }) - - it('reads the roster once when it first renders', async () => { - const actions = renderSection() - - await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - }) - - it('shows resolved copy for built-ins and falls back to custom ids', () => { - renderSection() - - // Display copy is what a picker reads; the id stays visible as the key the - // composition and the session header actually carry. - expect(screen.getByText(en.presetStandardName)).toBeTruthy() - expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() - const mine = rowFor('mine') - expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0) - expect(within(mine).getByText(en.noDescription)).toBeTruthy() - }) - - it('replaces the default preset trust tag with its new-task default status', () => { - renderSection() - - const standard = rowFor('standard') - expect(within(standard).queryByText(en.builtIn)).toBeNull() - expect(within(standard).getByText(en.inUse)).toBeTruthy() - expect(within(standard).queryByText(en.setDefault)).toBeNull() - expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy() - }) - - it('explains and reverses a Host-disabled picker without offering preset changes', () => { - const actions = renderSection({ - showPicker: false, - error: 'settings write disconnected', - rows: [ - ...READY.rows, - { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }, - ], - }) - - const toggle = screen.getByRole('switch', { name: en.showPicker }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(toggle) - expect(actions.setPickerVisible).toHaveBeenCalledWith(true) - expect(screen.getByRole('alert').textContent).toBe('settings write disconnected') - - const creator = screen.getByRole('button', { name: en.creatorDraft }) - expect(creator).toHaveProperty('disabled', true) - expect(creator.getAttribute('title')).toBe(en.enablePickerToCreate) - - const standard = within(rowFor('standard')).getByRole('button', { - name: `${en.selectionOffDefault}: ${en.presetStandardName}`, - }) - expect(standard.getAttribute('title')).toBe(en.selectionOffDefault) - expect(within(rowFor('standard')).getByText(en.selectionOffDefault)).toBeTruthy() - - const mine = within(rowFor('mine')).getByRole('button', { - name: `${en.enablePickerToSetDefault}: mine`, - }) - expect(mine).toHaveProperty('disabled', true) - expect(mine.getAttribute('title')).toBe(en.enablePickerToSetDefault) - fireEvent.click(mine) - expect(actions.makeDefault).not.toHaveBeenCalled() - }) - - it('separates built-in presets from custom ones', () => { - renderSection() - - // Two different things: one set ships with the deployment and is - // read-only, the other is the user's own. - expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy() - expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() - }) - - it('shows no group heading for a set nobody has', () => { - renderSection({ rows: [{ id: 'standard', trust: 'system', isDefault: true }] }) - - expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull() - }) - - it('leads with the two ways a preset is created', () => { - renderSection() - - // The page has no create button: the intro is what tells a first-time - // reader that copying an existing preset — or drafting one in Creator - // mode — IS the way to make one. - expect(screen.getByText(new RegExp('Creator mode'))).toBeTruthy() - }) - - it('picks a preset by clicking its card, and the one in use is inert', () => { - const actions = renderSection() - - const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: ${en.presetStandardName}` }) - expect(inUse).toHaveProperty('disabled', true) - fireEvent.click(inUse) - - // Clicking the card IS the choice; the preset already in use cannot be - // re-picked, so the click reaches nothing. - expect(actions.makeDefault).not.toHaveBeenCalled() - }) - - it('offers View on a shipped row and the location on a custom one', () => { - renderSection() - - // A shipped preset is the composition a copy starts from — reading it is - // the point. A custom preset is edited in its files, so its row leads - // there instead; there is no editor for either. - const standard = rowFor('standard') - expect(within(standard).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeTruthy() - expect(within(standard).queryByRole('button', { name: `${en.openLocation}: ${en.presetStandardName}` })).toBeNull() - const mine = rowFor('mine') - expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy() - expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull() - }) - - it('offers Delete only for a locally authored preset', () => { - renderSection() - - expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy() - expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: ${en.presetStandardName}` })).toBeNull() - }) - - it('disables duplication when nothing is writable, and says why', () => { - renderSection({ authorable: false }) - - const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: ${en.presetStandardName}` }) - expect(duplicate).toHaveProperty('disabled', true) - expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable) - }) - - it('marks a broken custom preset: unselectable, uncopyable, still deletable', () => { - const actions = renderSection({ - rows: [ - { id: 'standard', trust: 'system', isDefault: true }, - { - id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', description: '我自己写的', - broken: 'the composition file agent.cordis.yml is missing', - }, - ], - }) - - const ghost = rowFor('ghost') - // The badge carries the reason for a pointer, and the body cannot pick - // what cannot mount. - expect(within(ghost).getByText(en.brokenBadge).textContent) - .toBe(`${en.brokenBadge}the composition file agent.cordis.yml is missing`) - // A picker card keeps showing what the preset is; a package specifier in - // its place would tell a chooser nothing they can act on there. - expect(within(ghost).getByText('我自己写的')).toBeTruthy() - // Reachable without a pointer: the disabled body leaves the tab order, so - // this node is the only reading assistive technology gets. - expect(within(ghost).getByRole('alert').textContent).toContain('is missing') - // `aria-disabled`, not `disabled`: the card stays in the tab order so a - // keyboard reaches the reason the face no longer shows, and refuses the - // pick itself rather than by being unreachable. - const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` }) - expect(body).toHaveProperty('disabled', false) - expect(body.getAttribute('aria-disabled')).toBe('true') - fireEvent.click(body) - expect(actions.makeDefault).not.toHaveBeenCalled() - // Copying a broken preset would only mint another broken one; deleting - // and the location remain — the files are where it gets fixed. - const duplicate = within(ghost).getByRole('button', { name: `${en.duplicate}: 幽灵预设` }) - expect(duplicate).toHaveProperty('disabled', true) - expect(duplicate.getAttribute('data-tip')).toBe(en.brokenNoCopy) - expect(within(ghost).getByRole('button', { name: `${en.delete}: 幽灵预设` })).toBeTruthy() - expect(within(ghost).getByRole('button', { name: `${en.openLocation}: 幽灵预设` })).toBeTruthy() - }) - - it('withholds the viewer on a broken shipped preset', () => { - renderSection({ - rows: [{ id: 'standard', trust: 'system', isDefault: false, name: '标准模式', broken: 'the composition is not valid YAML' }], - }) - - // There is no readable composition to offer; the reason on the card is - // the whole story a shipped row can tell. - const standard = rowFor('standard') - expect(within(standard).queryByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeNull() - expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML') - }) - - it('labels the location by what it will do without a desktop', () => { - renderSection({ hasDocument: false }) - - expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy() - }) - - it('shows a revealed directory on its row', () => { - renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } }) - - const mine = rowFor('mine') - expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy() - expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy() - // The reveal belongs to its row alone. - expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull() - }) - - it('routes the row actions to the controller', () => { - const actions = renderSection() - - // The card body is the control that picks a preset. - fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` })) - fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` })) - fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` })) - fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })) - - expect(actions.makeDefault).toHaveBeenCalledWith('mine') - expect(actions.openLocation).toHaveBeenCalledWith('mine') - expect(actions.beginCopy).toHaveBeenCalledWith('mine') - expect(actions.view).toHaveBeenCalledWith('standard') - }) - - it('starts a creator-mode draft session and leaves settings', () => { - const actions = renderSection({ - rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], - }) - - fireEvent.click(screen.getByRole('button', { name: en.creatorDraft })) - - expect(actions.startCreatorDraft).toHaveBeenCalledTimes(1) - // Leaving settings is part of the gesture: the flow lands in the new - // session, not behind the modal. - expect(actions.close).toHaveBeenCalledTimes(1) - }) - - it('keeps the empty custom group on screen: heading plus the creator entry', () => { - renderSection({ - rows: [ - { id: 'standard', trust: 'system', isDefault: true, name: '标准模式' }, - { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }, - ], - }) - - // No member yet, but the place where one's own preset will appear stays. - expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() - expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy() - expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull() - }) - - it('hides the creator entry without the flow or the preset, disables it without a root', () => { - renderSection() - expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() - cleanup() - - renderSection({ - rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], - }, { creator: false }) - expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() - cleanup() - - const actions = renderSection({ - authorable: false, - rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], - }) - const disabled = screen.getByRole('button', { name: en.creatorDraft }) - expect(disabled).toHaveProperty('disabled', true) - fireEvent.click(disabled) - expect(actions.startCreatorDraft).not.toHaveBeenCalled() - }) - - it('shows a page-level failure without hiding the list', () => { - renderSection({ error: 'settings are read-only' }) - - expect(screen.getByRole('alert').textContent).toBe('settings are read-only') - expect(rowFor('mine')).toBeTruthy() - }) - - it('renders nothing when the deployment composes no presets', () => { - renderSection({ status: 'unavailable', rows: [] }) - expect(screen.queryByRole('heading')).toBeNull() - }) - - it('offers a retry when the roster could not be read', () => { - const actions = renderSection({ status: 'error', error: 'roster unavailable' }) - - expect(screen.getByRole('alert').textContent).toContain('roster unavailable') - fireEvent.click(screen.getByText(en.retry)) - - expect(actions.load).toHaveBeenCalledTimes(2) - }) +it('hides the complete picker-policy row while developer tools are off', () => { + view({}, undefined, false) + expect(screen.queryByRole('switch', { name: en.showPicker })).toBeNull() + expect(screen.queryByText(en.showPickerDescription)).toBeNull() + expect(screen.queryByText(en.showPickerBeta)).toBeNull() }) - -describe('preset help', () => { - it.each([ - ['standard', en.presetStandardName, 'How it works', 'Fix a bug'], - ['ptc', en.presetPtcName, 'How tools are called', 'Check a set of configuration files'], - ['minimal', en.presetMinimalName, 'What is included', 'Compare performance on a small bug fix'], - ['cordis', en.presetCordisName, 'What you can create', 'Add a UI'], - ])('opens both help sections for %s without changing the default', (id, name, heading, exampleTitle) => { - const actions = renderSection({ rows: [{ id, trust: 'system', isDefault: false }] }) - const trigger = within(rowFor(id)).getByRole('button', { name: `${en.modeExplanation}: ${name}` }) - trigger.focus() - fireEvent.click(trigger) - - const dialog = screen.getByRole('dialog', { name }) - expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy() - const usage = within(dialog).getByRole('tab', { name: en.howToUse }) - fireEvent.click(usage) - expect(usage.getAttribute('aria-selected')).toBe('true') - expect(within(dialog).getByRole('heading', { name: exampleTitle })).toBeTruthy() - expect(within(dialog).getAllByText(en.guideExampleTask).length).toBeGreaterThan(0) - fireEvent.click(within(dialog).getByRole('tab', { name: en.modeExplanation })) - expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy() - fireEvent.click(within(dialog).getByRole('button', { name: en.close })) - expect(document.activeElement).toBe(trigger) - - fireEvent.click(within(rowFor(id)).getByRole('button', { name: `${en.howToUse}: ${name}` })) - expect(within(screen.getByRole('dialog')).getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true') - expect(actions.makeDefault).not.toHaveBeenCalled() - expect(actions.view).not.toHaveBeenCalled() - expect(actions.startCreatorDraft).not.toHaveBeenCalled() - }) - - it('keeps keyboard focus in help and dismisses only the reader on Escape', () => { - const actions = renderSection() - const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) - trigger.focus() - fireEvent.click(trigger) - const dialog = screen.getByRole('dialog') - const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) - const panel = within(dialog).getByRole('tabpanel', { name: en.modeExplanation }) - const close = within(dialog).getByRole('button', { name: en.close }) - expect(document.activeElement).toBe(details) - expect(fireEvent.keyDown(details, { key: 'Tab' })).toBe(true) - panel.focus() - fireEvent.keyDown(panel, { key: 'Tab' }) - expect(document.activeElement).toBe(close) - fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) - expect(document.activeElement).toBe(panel) - fireEvent.keyDown(panel, { key: 'Escape' }) - expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(trigger) - expect(actions.close).not.toHaveBeenCalled() - }) - - it('closes even when the browser reports no previously focused element', () => { - const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) - try { - renderSection() - fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) - fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog')).toBeNull() - } finally { - activeElement.mockRestore() - } - }) - - it('connects keyboard selection to the visible guide panel', () => { - renderSection() +it('reads the roster once and sets a default from the card body', async () => { + const actions = view() + fireEvent.click(screen.getByRole('button', { name: `${en.setDefault}: Mine` })) + expect(actions.makeDefault).toHaveBeenCalledWith('mine') + await waitFor(() =>{ expect(actions.load).toHaveBeenCalledOnce() }) + expect(screen.queryByRole('button', { name: /^Edit plugins/ })).toBeNull() +}) +it('replaces the default preset group tag with its new-task default status', () => { + view() + const standard = rowFor('standard') + expect(within(standard).queryByText(en.builtInGroup)).toBeNull() + expect(within(standard).getByText(en.inUse)).toBeTruthy() + expect(within(standard).queryByText(en.setDefault)).toBeNull() + expect(within(rowFor('mine')).getByText(en.customGroup)).toBeTruthy() +}) +it('omits an empty group instead of leaving a heading behind', () => { + view({ rows: [{ id: 'standard', isDefault: true }] }) + expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy() + expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull() +}) +it('explains that presets come from bundles and launches Creator from the guidance', () => { + const launch = vi.fn() + const actions = view({ rows: [{ id: 'cordis', isDefault: true }] }, launch) + fireEvent.click(screen.getByRole('button', { name: en.authoringHelp })) + const dialog = screen.getByRole('dialog', { name: en.authoringTitle }) + expect(within(dialog).getByText(en.authoringBody)).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: en.creatorDraft })) + expect(launch).toHaveBeenCalledOnce() + expect(actions.close).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog', { name: en.authoringTitle })).toBeNull() +}) +it('offers no Creator action without the conversation flow or the cordis preset', () => { + view({ rows: [{ id: 'cordis', isDefault: true }] }) + fireEvent.click(screen.getByRole('button', { name: en.authoringHelp })) + expect(within(screen.getByRole('dialog')).queryByRole('button', { name: en.creatorDraft })).toBeNull() + fireEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: en.close }).at(-1)!) + cleanup() + view({}, vi.fn()) + fireEvent.click(screen.getByRole('button', { name: en.authoringHelp })) + expect(within(screen.getByRole('dialog')).queryByRole('button', { name: en.creatorDraft })).toBeNull() +}) +it('dismisses the guidance from its header close', () => { + view() + fireEvent.click(screen.getByRole('button', { name: en.authoringHelp })) + const dialog = screen.getByRole('dialog', { name: en.authoringTitle }) + fireEvent.click(within(dialog).getAllByRole('button', { name: en.close })[0]!) + expect(screen.queryByRole('dialog', { name: en.authoringTitle })).toBeNull() +}) +it('disables Creator in the guidance when mode selection is hidden', () => { + const launch = vi.fn() + view({ showPicker: false, rows: [{ id: 'cordis', isDefault: true }] }, launch) + fireEvent.click(screen.getByRole('button', { name: en.authoringHelp })) + const button = within(screen.getByRole('dialog')).getByRole('button', { name: en.creatorDraft }) + expect(button.disabled).toBe(true) + expect(button.title).toBe(en.enablePickerToCreate) + fireEvent.click(button) + expect(launch).not.toHaveBeenCalled() +}) +it('shows roster errors while the policy switch stays usable', () => { + const actions = view({ error: 'Roster stale', rows: [{ id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) + expect(screen.getAllByRole('alert').map(node => node.textContent)).toEqual(['Roster stale', 'Missing plugin']) + fireEvent.click(screen.getByRole('switch')) + expect(actions.setPickerVisible).toHaveBeenCalledWith(false) +}) +it('identifies the effective default when the picker is hidden', () => { + view({ showPicker: false }) + expect(screen.getByRole('button', { name: `${en.selectionOffDefault}: ${en.presetStandardName}` }).disabled).toBe(true) +}) +it('keeps a broken card focusable for diagnostics and refuses to select it', () => { + const actions = view({ rows: [{ id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) + const card = screen.getByRole('button', { name: 'Failed to load: broken' }) + expect(card.disabled).toBe(false) + expect(card.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(card) + expect(actions.makeDefault).not.toHaveBeenCalled() +}) +it.each([ + ['standard', en.presetStandardName, 'How it works', 'Fix a bug'], + ['ptc', en.presetPtcName, 'How tools are called', 'Check a set of configuration files'], + ['minimal', en.presetMinimalName, 'What is included', 'Compare performance on a small bug fix'], + ['cordis', en.presetCordisName, 'What you can create', 'Add a UI'], +])('opens both help sections for %s without changing the default', (id, name, heading, exampleTitle) => { + const actions = view({ rows: [{ id, isDefault: false }] }) + const trigger = within(rowFor(id)).getByRole('button', { name: `${en.modeExplanation}: ${name}` }) + trigger.focus() + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog', { name }) + expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy() + const usage = within(dialog).getByRole('tab', { name: en.howToUse }) + fireEvent.click(usage) + expect(usage.getAttribute('aria-selected')).toBe('true') + expect(within(dialog).getByRole('heading', { name: exampleTitle })).toBeTruthy() + expect(within(dialog).getAllByText(en.guideExampleTask).length).toBeGreaterThan(0) + fireEvent.click(within(dialog).getByRole('tab', { name: en.modeExplanation })) + expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: en.close })) + expect(document.activeElement).toBe(trigger) + fireEvent.click(within(rowFor(id)).getByRole('button', { name: `${en.howToUse}: ${name}` })) + expect(within(screen.getByRole('dialog')).getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true') + expect(actions.makeDefault).not.toHaveBeenCalled() +}) +it('keeps keyboard focus in help and dismisses only the reader on Escape', () => { + const actions = view() + const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }) + trigger.focus() + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog') + const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) + const panel = within(dialog).getByRole('tabpanel', { name: en.modeExplanation }) + const close = within(dialog).getByRole('button', { name: en.close }) + expect(document.activeElement).toBe(details) + expect(fireEvent.keyDown(details, { key: 'Tab' })).toBe(true) + panel.focus() + fireEvent.keyDown(panel, { key: 'Tab' }) + expect(document.activeElement).toBe(close) + fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(panel) + fireEvent.keyDown(panel, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(actions.close).not.toHaveBeenCalled() +}) +it('connects keyboard selection to the visible guide panel', () => { + view() + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) + const dialog = screen.getByRole('dialog') + const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) + const usage = within(dialog).getByRole('tab', { name: en.howToUse }) + expect(details.tabIndex).toBe(0) + expect(usage.tabIndex).toBe(-1) + fireEvent.keyDown(details, { key: 'ArrowRight' }) + const panel = within(dialog).getByRole('tabpanel', { name: en.howToUse }) + expect(panel.id).toBe(usage.getAttribute('aria-controls')) + expect(document.activeElement).toBe(usage) + expect(usage.getAttribute('aria-selected')).toBe('true') + expect(details.tabIndex).toBe(-1) + expect(usage.tabIndex).toBe(0) + expect(within(dialog).queryByRole('tabpanel', { name: en.modeExplanation })).toBeNull() +}) +it('does not attach built-in claims to named or unknown presets', () => { + view({ rows: [{ id: 'ptc', name: 'My PTC', isDefault: false }, { id: 'third-party', isDefault: false }] }) + expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull() + expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull() +}) +it('leaves help usable when mode selection is disabled', () => { + const actions = view({ showPicker: false }) + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` })) + expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() + expect(actions.setPickerVisible).not.toHaveBeenCalled() +}) +it('closes help even when the browser reports no previously focused element', () => { + const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) + try { + view() fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })) - const dialog = screen.getByRole('dialog') - const details = within(dialog).getByRole('tab', { name: en.modeExplanation }) - const usage = within(dialog).getByRole('tab', { name: en.howToUse }) - expect(details.tabIndex).toBe(0) - expect(usage.tabIndex).toBe(-1) - fireEvent.keyDown(details, { key: 'ArrowRight' }) - const panel = within(dialog).getByRole('tabpanel', { name: en.howToUse }) - expect(panel.id).toBe(usage.getAttribute('aria-controls')) - expect(document.activeElement).toBe(usage) - expect(usage.getAttribute('aria-selected')).toBe('true') - expect(details.tabIndex).toBe(-1) - expect(usage.tabIndex).toBe(0) - expect(within(dialog).queryByRole('tabpanel', { name: en.modeExplanation })).toBeNull() - }) - - it('does not attach built-in claims to custom or unknown presets', () => { - renderSection({ - rows: [ - { id: 'ptc', trust: 'user', isDefault: false }, - { id: 'third-party', trust: 'system', isDefault: false }, - ], - }) - expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull() - expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull() - }) - - it('leaves help usable when mode selection is disabled', () => { - const actions = renderSection({ showPicker: false }) - fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` })) - expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy() - expect(actions.setPickerVisible).not.toHaveBeenCalled() - }) -}) - -describe('the copy dialog', () => { - const draft: CopyDraft = { - from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null, + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog')).toBeNull() + } finally { + activeElement.mockRestore() } - - it('names its source and collects only an id and a display name', () => { - const actions = renderSection({ copy: draft }) - - const dialog = screen.getByRole('dialog') - expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} ${en.presetStandardName}`) - expect(within(dialog).getByText(en.copyIntro)).toBeTruthy() - fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } }) - fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } }) - - expect(actions.setCopyId).toHaveBeenCalledWith('my-agent') - expect(actions.setCopyName).toHaveBeenCalledWith('我的模式') - // Nothing else is collected: the description and the composition are - // edited in the preset's own files. - expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull() - }) - - it('creates and cancels through the controller', () => { - const actions = renderSection({ copy: { ...draft, id: 'my-agent' } }) - - const dialog = screen.getByRole('dialog') - fireEvent.click(within(dialog).getByText(en.create)) - fireEvent.click(within(dialog).getByText(en.cancel)) - - expect(actions.confirmCopy).toHaveBeenCalledTimes(1) - expect(actions.cancelCopy).toHaveBeenCalledTimes(1) - }) - - it('blocks a copy the host would refuse, and says why', () => { - const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } }) - - const dialog = screen.getByRole('dialog') - expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid) - fireEvent.click(within(dialog).getByText(en.create)) - - // Disabled rather than round-tripping: the id is a directory name and the - // rule is the host's own. - expect(actions.confirmCopy).not.toHaveBeenCalled() - }) - - it('shows the host\'s refusal instead of the local blocker', () => { - renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } }) - - expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists') - }) - - it('reports a copy in flight and blocks a second click', () => { - const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } }) - - fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating)) - - expect(actions.confirmCopy).not.toHaveBeenCalled() - }) - - it('dismisses on Escape', () => { - const actions = renderSection({ copy: draft }) - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(actions.cancelCopy).toHaveBeenCalledTimes(1) - }) }) - -describe('the read-only viewer', () => { - it('shows the composition text under the preset\'s name', () => { - renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } }) - - const dialog = screen.getByRole('dialog') - expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · ${en.presetStandardName}`) - expect(within(dialog).getByText(en.composition)).toBeTruthy() - expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n') - }) - - it('keeps the loaded title when the viewed row leaves the roster', () => { - renderSection({ view: { id: 'retired', title: 'Retired mode', content: '- id: tool-bash\n' } }) - - expect(screen.getByRole('dialog').getAttribute('aria-label')).toBe(`${en.view} · Retired mode`) - }) - - it('closes through the controller', () => { - const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) - - fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close)) - - expect(actions.closeView).toHaveBeenCalledTimes(1) - }) - - it('dismisses on Escape', () => { - const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(actions.closeView).toHaveBeenCalledTimes(1) - }) -}) - -describe('deleting a preset', () => { - it('asks before deleting', () => { - const actions = renderSection() - - fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })) - - expect(actions.confirmDelete).toHaveBeenCalledWith('mine') - }) - - it('confirms and dismisses through the controller', () => { - const actions = renderSection({ pendingDelete: 'mine' }) - - const dialog = screen.getByRole('dialog') - fireEvent.click(within(dialog).getByText(en.deleteConfirm)) - fireEvent.click(within(dialog).getByText(en.cancel)) - - expect(actions.remove).toHaveBeenCalledTimes(1) - expect(actions.confirmDelete).toHaveBeenLastCalledWith(null) - }) - - it('dismisses the confirmation on Escape', () => { - const actions = renderSection({ pendingDelete: 'mine' }) - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(actions.confirmDelete).toHaveBeenCalledWith(null) - }) - - it('reports a delete in flight', () => { - const actions = renderSection({ pendingDelete: 'mine', deleting: true }) - - fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting)) - - expect(actions.remove).not.toHaveBeenCalled() - }) -}) - -describe('a long card description', () => { - /** jsdom has no ResizeObserver; the description watches its own box through one. */ - class ResizeObserverStub { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } - - const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8) - - /** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */ - function clamp(overflowing: boolean): void { - vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80) - vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80) - } - - beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) - afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) - - it('offers the whole description on hover once the card cuts it off', () => { - clamp(true) - vi.useFakeTimers() - try { - renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] }) - - fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG)) - act(() => { vi.advanceTimersByTime(400) }) - - expect(screen.getByRole('tooltip').textContent).toBe(LONG) - } finally { - vi.useRealTimers() - } - }) - - it('stays quiet when the description already fits', () => { - clamp(false) - vi.useFakeTimers() - try { - renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] }) - - fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。')) - act(() => { vi.advanceTimersByTime(400) }) - - // A bubble repeating what is already fully on the card is noise. - expect(screen.queryByRole('tooltip')).toBeNull() - } finally { - vi.useRealTimers() - } - }) - - it('renders where the runtime has no ResizeObserver', () => { - vi.unstubAllGlobals() - clamp(true) - - expect(() => { - renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] }) - }).not.toThrow() - // The first measurement does not depend on the observer. - expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('') - }) +it.each([false, true])('only offers a description tooltip when the card clips it: %s', (overflow) => { + vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflow ? 400 : 80) + vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80) + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect }) + vi.useFakeTimers() + view({ rows: [{ id: 'mine', isDefault: false, description: 'Preset description' }] }) + fireEvent.mouseEnter(screen.getByText('Preset description')) + act(() => { vi.advanceTimersByTime(400) }) + expect(screen.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Preset description' : null) + cleanup() + expect(disconnect).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 825f72adb1..65f7bb0464 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -28,7 +28,7 @@ interface Recorded { ns: string; ops: unknown } /** A roster Remote answering a fixed set of rows, or refusing. */ function fakeRoster( - presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + presets: { id: string; isDefault: boolean }[], options: { failList?: string failListCode?: RemoteErrorCode @@ -45,7 +45,7 @@ function fakeRoster( ? { ok: true as const, value: { - presets, authorable: true, modeSelectionEnabled: options.showPicker ?? true, + presets, modeSelectionEnabled: options.showPicker ?? true, }, } : { @@ -60,7 +60,7 @@ function fakeRoster( /** A context whose roster and settings write outcome the test controls. */ function fakeApi( - presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + presets: { id: string; isDefault: boolean }[], options: { writes?: Recorded[] failWrite?: string @@ -88,8 +88,8 @@ function fakeApi( describe('the agent-preset roster store', () => { it('derives the display options from one roster call', async () => { const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'mine', trust: 'user', isDefault: false }, + { id: 'standard', isDefault: true }, + { id: 'mine', isDefault: false }, ])) await controller.load() @@ -97,28 +97,28 @@ describe('the agent-preset roster store', () => { const state = controller.store.getSnapshot() expect(state.status).toBe('ready') expect(state.options).toEqual([ - { id: 'standard', trust: 'system' }, - { id: 'mine', trust: 'user' }, + { id: 'standard' }, + { id: 'mine' }, ]) }) it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => { const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' }, + { id: 'standard', isDefault: true }, + { id: 'damaged', isDefault: false, broken: 'the composition is not valid YAML' }, ] as never)) await controller.load() // A broken preset cannot compose a session; listing it here would defer // that discovery to a failed session start. The management section shows - // (and deletes) it from its own store instead. + // and edits it from its own store instead. expect(controller.store.getSnapshot().options.map(option => option.id)).toEqual(['standard']) }) it('carries the display metadata a preset published', async () => { const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + { id: 'standard', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, ] as never)) await controller.load() @@ -126,7 +126,7 @@ describe('the agent-preset roster store', () => { // Surfaces beyond this row read the same options; the id alone never said // what a preset does. expect(controller.store.getSnapshot().options).toEqual([ - { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + { id: 'standard', name: '标准模式', description: '完整的编码 agent。' }, ]) }) @@ -155,8 +155,8 @@ describe('the agent-preset roster store', () => { it('writeDefaultPreset writes only the default field, into the agent-presets namespace', async () => { const writes: Recorded[] = [] const ctx = fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'minimal', trust: 'system', isDefault: false }, + { id: 'standard', isDefault: true }, + { id: 'minimal', isDefault: false }, ], { writes }) expect(await writeDefaultPreset(ctx, 'minimal')).toBeUndefined() @@ -169,7 +169,7 @@ describe('the agent-preset roster store', () => { it('writeDefaultPreset surfaces the refusal message when the write fails', async () => { const ctx = fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, + { id: 'standard', isDefault: true }, ], { failWrite: 'read-only settings' }) expect(await writeDefaultPreset(ctx, 'minimal')).toBe('read-only settings') @@ -198,7 +198,7 @@ describe('the agent-preset roster store', () => { it('ignores a load while one is already in flight', async () => { const writes: Recorded[] = [] const controller = derivedController(fakeApi( - [{ id: 'standard', trust: 'system', isDefault: true }], { writes })) + [{ id: 'standard', isDefault: true }], { writes })) await Promise.all([controller.load(), controller.load()]) @@ -210,7 +210,7 @@ describe('the agent-preset roster store', () => { describe('the new-session chip controller', () => { /** A chip over a current session the test can move. */ function chip( - presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + presets: { id: string; isDefault: boolean }[], current: SeatSession | undefined | (() => SeatSession | undefined), options: { writes?: Recorded[] @@ -229,7 +229,7 @@ describe('the new-session chip controller', () => { ? { ok: true as const, value: { - presets, authorable: true, modeSelectionEnabled: options.showPicker ?? true, + presets, modeSelectionEnabled: options.showPicker ?? true, }, } : { @@ -257,9 +257,9 @@ describe('the new-session chip controller', () => { ) } - const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'minimal', trust: 'system', isDefault: false }, + const ROSTER: { id: string; isDefault: boolean }[] = [ + { id: 'standard', isDefault: true }, + { id: 'minimal', isDefault: false }, ] it('opens on the deployment default', async () => { @@ -271,8 +271,8 @@ describe('the new-session chip controller', () => { // decided yet — the default is the honest opening value. expect(controller.store.getSnapshot().current).toBe('standard') expect(controller.store.getSnapshot().options).toEqual([ - { id: 'standard', trust: 'system' }, - { id: 'minimal', trust: 'system' }, + { id: 'standard' }, + { id: 'minimal' }, ]) }) @@ -295,7 +295,7 @@ describe('the new-session chip controller', () => { }) it('shows the first preset when the roster marks none default', async () => { - const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined) + const controller = chip([{ id: 'minimal', isDefault: false }], undefined) await controller.load() @@ -306,13 +306,13 @@ describe('the new-session chip controller', () => { it('carries the display metadata into the menu rows', async () => { const controller = chip([ - { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + { id: 'standard', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, ] as never, undefined) await controller.load() expect(controller.store.getSnapshot().options).toEqual([ - { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + { id: 'standard', name: '标准模式', description: '完整的编码 agent。' }, ]) }) @@ -352,8 +352,8 @@ describe('the new-session chip controller', () => { it('replaces the default display when an existing blank session arrives after roster load', async () => { const state: { current?: SeatSession } = {} const controller = chip([ - { id: 'standard', trust: 'system', isDefault: false }, - { id: 'minimal', trust: 'system', isDefault: true }, + { id: 'standard', isDefault: false }, + { id: 'minimal', isDefault: true }, ], () => state.current) await controller.load() expect(controller.store.getSnapshot().current).toBe('minimal') @@ -511,8 +511,7 @@ function remoteRoster(modeSelectionEnabled: boolean) { return { ok: true as const, value: { - presets: [{ id: 'standard', trust: 'system' as const, isDefault: true }], - authorable: true, + presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled, }, } diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index aa5818b662..f7510db80a 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -48,7 +48,7 @@ "path": "../../api/session-controller/tsconfig.client.json" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../core/session" diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index a55dda8361..46d1797a0c 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/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/client/ui-settings-plugin-inventory/README.md -README.md: 2c456dfef05444a04f6282ca2c1bb4a2ba8bb0ce -README.zh.md: b69803627371dee9f543dceed1282fcf17a7b48c +README.md: 7353f14d111aa1c89c3331a3d4977ddfbf950294 +README.zh.md: 9078f8074a10688cb7370ec7ee5fd8af16ca48c1 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 2c456dfef0..7353f14d11 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -33,7 +33,7 @@ A failed installation with pending pnpm build permissions offers **Allow these s Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. -The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by localized title and description, module name, and entry id. +The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. ### The preset switcher diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index b698036273..9078f8074a 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" 每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 -稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 +稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 ### 预设切换器 diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 1f98f2a539..4e11b38798 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -60,7 +60,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^" }, diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index 7d7117554f..5c8b0fd7a0 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -9,7 +9,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // dictionaries the shipped-preset name resolution below reads. import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client' // Inline-safe shared fold: shipped ids map to dictionary keys in one home. -import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display' +import { presetDisplayText } from '@deepseek-ai/dsh-agent-preset-registry/display' import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx' import { en, zh, type PluginInventoryLocaleKey } from './locales.ts' diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index 22b7e218ae..ea3d8ade8c 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -89,8 +89,8 @@ describe('ui-settings-plugin-inventory browser plugin', () => { // Shipped preset names resolve over the agent-preset dictionaries the // real plugin registers; user-authored metadata stays untranslated. b.locale.register('settings.agentPreset', 'zh', { presetStandardName: '标准模式' } as never) - expect(injected.presetName({ id: 'standard', trust: 'system', isDefault: true, rows: [] })).toBe('标准模式') - expect(injected.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的') + expect(injected.presetName({ id: 'standard', isDefault: true, rows: [] })).toBe('标准模式') + expect(injected.presetName({ id: 'mine', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的') await b.ctx.fiber.dispose() }) diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index de7274e7a5..fc54c1e384 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -66,7 +66,6 @@ const SNAPSHOT = { agentPresets: [ { id: 'standard', - trust: 'system', name: '标准模式', isDefault: true, rows: [ @@ -86,7 +85,6 @@ const SNAPSHOT = { }, { id: 'ptc', - trust: 'system', isDefault: false, rows: [ { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null }, @@ -94,7 +92,7 @@ const SNAPSHOT = { { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: 'conditional', fiberPhase: null }, ], }, - { id: 'shattered', trust: 'user', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] }, + { id: 'shattered', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] }, ], } as unknown as Snapshot @@ -128,7 +126,7 @@ describe('PluginInventorySettingsTab', () => { }))) const snapshot: Snapshot = scope === 'global' ? { entries: rows } - : { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] } + : { entries: [], agentPresets: [{ id: 'custom', isDefault: true, rows }] } const view = await renderReady(snapshot) fireEvent.click(scope === 'global' ? globalToggle() : presetToggle()) for (const [index, [moduleName, title]] of names.entries()) { @@ -180,7 +178,7 @@ describe('PluginInventorySettingsTab', () => { }, }], agentPresets: [{ - id: 'custom', trust: 'user', name: 'My preset', isDefault: true, + id: 'custom', name: 'My preset', isDefault: true, rows: [{ entryId: 'include:preset-runner', moduleName: '@acme/runner', enabled: true, fiberPhase: null, meta: { @@ -253,7 +251,7 @@ describe('PluginInventorySettingsTab', () => { ] const snapshot: Snapshot = scope === 'global' ? { entries: rows } - : { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] } + : { entries: [], agentPresets: [{ id: 'custom', isDefault: true, rows }] } const list = vi.fn(async () => snapshot) const { locale, pageProps } = localizedProps(list) const view = render() @@ -376,7 +374,6 @@ describe('PluginInventorySettingsTab', () => { ], agentPresets: [{ id: 'standard', - trust: 'system', isDefault: true, rows: [ { entryId: 'stopping', moduleName: '@fixture/stopping', enabled: true, fiberPhase: 'unloading' }, @@ -402,7 +399,6 @@ describe('PluginInventorySettingsTab', () => { entries: [], agentPresets: [{ id: 'same-module', - trust: 'user', isDefault: true, rows: [ { entryId: 'tool-subagent-primary', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null }, @@ -511,7 +507,7 @@ describe('PluginInventorySettingsTab', () => { // The resolver stands in for presetDisplayText: shipped presets localize, // user-authored ones keep their own metadata. const localized: PluginInventorySettingsTabInjected['presetName'] = preset => - preset.trust === 'system' ? `Localized ${preset.id}` : preset.name ?? preset.id + ['standard', 'ptc'].includes(preset.id) ? `Localized ${preset.id}` : preset.name ?? preset.id render( SNAPSHOT, localized)} />) await screen.findByRole('searchbox', { name: en.search }) @@ -605,7 +601,6 @@ describe('PluginInventorySettingsTab', () => { entries: [], agentPresets: [{ id: 'solo', - trust: 'user', isDefault: false, rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }], }], diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json index 491b34792f..a5c2ce04da 100644 --- a/packages/client/ui-settings-plugin-inventory/tsconfig.json +++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json @@ -27,7 +27,7 @@ "path": "../ui-agent-preset" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../ui-primitives" diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 22a2db2519..a359f00231 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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/client/ui-settings-plugins/README.md -README.md: 7d09cfb4f7d77cb8a121e043d94b62cd4ccd823f -README.zh.md: 649c5c51219856e8d8544db0d4d291343db55c64 +README.md: d3bda55183f64c6e488e73966f43359bab58ed52 +README.zh.md: 39e14727d871167d76d152eb0b4ba38a34a2cdcc diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 7d09cfb4f7..d3bda55183 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -97,7 +97,7 @@ None; this package neither assembles nor sends a provider request. These limits define which plugins get a page and how fresh the group is; they are current package constraints. -- **Only host-plane plugins have a page** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all, so this package registers nothing for it. Editing those values remains the preset editor's job. +- **Only host-plane plugins have a page** — a plugin an agent preset mounts carries its configuration inline in that preset's `config.plugins` and cannot register a settings namespace at all, so this package registers nothing for it. Editing those values remains the preset editor's job. - **A page still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `../../../packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. - **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the mirror's read joins the Official group on the next document commit or reconnect. - **The shell page follows the composed executor** — the POSIX and PowerShell executor families share the `shell` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the page edits the same two fields on both. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 649c5c5121..39e14727d8 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -97,7 +97,7 @@ kind: "package-reference" 这些限制定义哪些插件会有页面、分组有多新鲜;它们是当前包约束。 -- **只有宿主平面的插件有页面**:由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间,因此本包不会为它注册任何东西。编辑那些值仍是 preset 编辑器的职责。 +- **只有宿主平面的插件有页面**:由 agent preset 挂载的插件把配置内联在该 preset 的 `config.plugins` 中,且根本无法注册 settings 命名空间,因此本包不会为它注册任何东西。编辑那些值仍是 preset 编辑器的职责。 - **页面仍然需要一份浏览器 bundle**:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `../../../packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。 - **被服务的命名空间只在两种信号上重读**:协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在镜像读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入官方分组。 - **shell 页面跟随被组装的执行器**:POSIX 与 PowerShell 两个执行器家族共用 `shell` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管页面在两者下编辑的都是同样两个字段。 diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index b017ca2e72..b19adfd10f 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/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/core/agent-tool-presentation/README.md -README.md: c7aadfc9f409392718d7f044168c5de48dd33ee5 -README.zh.md: 663bb011f3a6f1cc0f257bc3383f073b23dbda02 +README.md: fe45190931fd35ae79002f466f2ff6476a6b96d1 +README.zh.md: 62d7ce3ad5f1d0cafd7bebc5aca73bdc9c98e50e diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index c7aadfc9f4..fe45190931 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-agent-tool-presentation` in an [agent preset](../../preset/agent-presets/README.md) to fix whether models see every native tool schema, only `run_code` with a generated SDK, or both forms. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` or `both` requires a compatible PTC runtime; a deployment without one rejects the preset at mount time before its first prompt. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. +Use `dsh-agent-tool-presentation` in an [agent preset](../../preset/agent-preset-registry/README.md) to fix whether models see every native tool schema, only `run_code` with a generated SDK, or both forms. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` or `both` requires a compatible PTC runtime; a deployment without one rejects the preset at mount time before its first prompt. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. ## Table of Contents @@ -72,7 +72,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane ### Behavior notes -`native` applies immediately. A PTC mode instead waits for `ctx.ptcRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. `presentAs` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it. +`native` applies immediately. A PTC mode instead waits for `ctx.ptcRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-preset-registry` refuses the mount naming this id. `presentAs` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it.
@@ -84,7 +84,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane The package-level contract is enough for most consumers; read these when you need the surrounding domain. - [tools package](../tools/README.md) — the tool presentation modes and `presentAs` API. -- [agent-presets package](../../preset/agent-presets/README.md) — how presets compose agents and their standing mounts. +- [agent-presets package](../../preset/agent-preset-registry/README.md) — how presets compose agents and their standing mounts. - [Node ptc-runtime package](../../ptc-runtime/ptc-runtime-node/README.md) — the TypeScript runtime a PTC mode needs. - [PTC mode executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md) — why the announced and callable surfaces stay the same. - [Core group map](../README.md) — how the core packages compose. diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index 663bb011f3..62d7ce3ad5 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 [agent preset](../../preset/agent-presets/README.zh.md) 中使用 `dsh-agent-tool-presentation`,可固定模型看到全部原生工具 schema、只有带生成 SDK 的 `run_code`,还是同时看到两种形态。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 或 `both` 需要兼容的 PTC 运行时;没有该运行时的部署会在挂载时拒绝 preset,不会等到收到第一条提示词。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 +在 [agent preset](../../preset/agent-preset-registry/README.zh.md) 中使用 `dsh-agent-tool-presentation`,可固定模型看到全部原生工具 schema、只有带生成 SDK 的 `run_code`,还是同时看到两种形态。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 或 `both` 需要兼容的 PTC 运行时;没有该运行时的部署会在挂载时拒绝 preset,不会等到收到第一条提示词。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 ## 目录 @@ -72,7 +72,7 @@ kind: "package-reference" ### 行为说明 -`native` 立即生效。PTC 模式则等待 `ctx.ptcRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 +`native` 立即生效。PTC 模式则等待 `ctx.ptcRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-preset-registry` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 @@ -84,7 +84,7 @@ kind: "package-reference" 包级约定对大多数消费方已经足够;需要周边领域时再阅读以下页面。 - [tools 包](../tools/README.zh.md)——工具呈现模式与 `presentAs` API。 -- [agent-presets 包](../../preset/agent-presets/README.zh.md)——preset 如何组合 agent 及其常驻挂载。 +- [agent-presets 包](../../preset/agent-preset-registry/README.zh.md)——preset 如何组合 agent 及其常驻挂载。 - [Node ptc-runtime 包](../../ptc-runtime/ptc-runtime-node/README.zh.md)——PTC 模式所需的 TypeScript 运行时。 - [PTC mode 执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md)——通告面与可调用面为何保持一致。 - [core 分组地图](../README.zh.md)——core 各包如何组合。 diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index 221f424386..567a673549 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -65,7 +65,7 @@ export function apply(ctx: Context, config: Config): void { return } // The wait is the loud failure: an entry still pending on `ptcRuntime` is - // what `dsh-agent-presets` reports as an unusable row, naming this id. + // what `dsh-agent-preset-registry` reports as an unusable row, naming this id. ctx.inject(['ptcRuntime'], (runtimeCtx: Context) => { runtimeCtx.tools.presentAs(config.mode) }) diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index 3621223528..0f70ebf8e0 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -105,7 +105,7 @@ describe('the tool-presentation row', () => { const { agent, row } = await mount(ctx, { mode: 'ptc' }) - // Pending, not applied: `dsh-agent-presets` rejects a mount holding a row + // Pending, not applied: `dsh-agent-preset-registry` rejects a mount holding a row // that never reached a usable state, naming this id — so the preset fails // where the operator can act, instead of at the first request. expect(row.ctx.get('ptcRuntime')).toBeUndefined() diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 2514c5a599..cd21241810 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -97,8 +97,8 @@ const subjectBuilt = [ 'packages/host/webserver/lib/index.js', 'packages/llm/plugin-package-inventory-deepseek/lib/index.js', 'native/system/packages/entry/lib/index.js', - 'packages/preset/agent-presets/lib/typert.host.js', - 'packages/preset/agent-presets/lib/typert.remote-client.js', + 'packages/preset/agent-preset-registry/lib/typert.host.js', + 'packages/preset/agent-preset-registry/lib/typert.remote-client.js', ].every(path => existsSync(join(repoRoot, path))) let memo: ReturnType | undefined diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 96ccbc1151..6772e504c2 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: f4e412e25e70fe74d66018bf3a0cb66a550603e4 -README.zh.md: 5ae13f0ae49961cfe67d5b567999f500fe7f6f7a +README.md: b3a474a3cd700289497283d617dc4f502c0be662 +README.zh.md: 94f3f82f204058084ac27b7f9fdc350d0edb519c diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index f4e412e25e..b3a474a3cd 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -27,7 +27,7 @@ The public npm package exposes the runtime library at its root, the self-contain Three build artifacts and one source-owned process layer: -- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. +- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches select plaintext JSONL session logs. Preset declarations are part of the composed configuration. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The `node:util` JSON-data comparator compares own enumerable properties, preserving Node admission decisions for recorded fields such as `__proto__`. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`. - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 5ae13f0ae4..94f3f82f20 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -27,7 +27,7 @@ kind: "package-library" 一条 tsdown 管线产出三个构建产物;另有一层由源码维护的进程实现: -- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 +- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 选择明文 JSONL 会话日志。预设声明包含在组合后的配置中。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。`node:util` 的 JSON 数据比较器检查可枚举的自有属性,使 `__proto__` 等记录字段的准入判定与 Node 一致。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。 diff --git a/packages/experimental/webworker-runtime/src/storage/paths.ts b/packages/experimental/webworker-runtime/src/storage/paths.ts index 05151ea60f..93ec258baa 100644 --- a/packages/experimental/webworker-runtime/src/storage/paths.ts +++ b/packages/experimental/webworker-runtime/src/storage/paths.ts @@ -13,7 +13,7 @@ export const DSH_HOME = `${DSH_ROOT}/home` /** Flat, symlink-free package tree resolved by the worker module loader. */ export const DSH_NODE_MODULES = `${DSH_ROOT}/node_modules` -/** Directory holding the composed cordis.yml and the agent-preset tree. */ +/** Directory holding the composed cordis.yml. */ export const DSH_CONFIG = `${DSH_ROOT}/config` /** Default (empty) workspace directory. */ diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 3c96174f8d..1d0853f9f7 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -230,7 +230,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { provideCmdline(ctx: unknown, host: { args: readonly string[]; exit: (code: number) => void }): void } - const { patches, presetOverlay } = bootPatches(loader, mounted, configPath, root) + const { patches } = bootPatches(loader, mounted, configPath, root) const ctx = await appBoot.boot('dsh-webworker', configPath, patches, (hostCtx) => { // Before any entry mounts: the Loader would otherwise fall back to the // runtime's own dynamic import for every row. @@ -251,7 +251,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { } const handler = connection.createSharedFetchHandler('/api') const usage = loader.usage() - console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), @@ -348,29 +348,19 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void { } } -/** - * The shipped preset root, as the application layer that owns the composition - * supplies it. - * - * A launcher appends this root itself rather than writing it into the roster — - * `apps/cli` does it in `composeProfile` (`profile-boot.ts:159-166`) because only - * the application knows where its own presets sit. The worker's presets travel - * in the image, so the same overlay names their virtual path. Patching replaces - * a row's whole `config`, so the current one is read and spread, and a roster - * that already names roots keeps them. - * @param loader - Module loader, for the image's YAML reader. - * @param vfs - Filesystem holding the composed configuration. - * @param configPath - Composed configuration path. - * @param root - Virtual root. - * @returns Boot patches (preset root overlay, frontend serving off) and - * whether the preset overlay was applied. +/** Select plaintext session persistence for the in-memory Worker filesystem. + * @param loader Module loader for the image's YAML reader. + * @param vfs Filesystem holding the composed configuration. + * @param configPath Composed configuration path. + * @param root Virtual root used to resolve YAML packages. + * @returns Boot patches for the configured JSONL provider. */ function bootPatches( loader: WorkerModuleLoader, vfs: MemoryVfs, configPath: string, root: string, -): { patches: unknown[]; presetOverlay: boolean } { +): { patches: unknown[] } { const text = vfs.readFileSync(configPath, 'utf8') as string let rows: unknown if (configPath.endsWith('.json')) { @@ -396,22 +386,13 @@ function bootPatches( : {}) as Record const patches: unknown[] = [] - let presetOverlay = false - const presets = find(rows, 'agent-presets') - if (presets !== undefined && configOf(presets).roots === undefined) { - presetOverlay = true - patches.push({ - id: 'agent-presets', - config: { ...configOf(presets), roots: [{ path: join(root, 'config/agent-presets'), trust: 'system' }] }, - }) - } // The worker carries no compression codec, and the VFS is in-memory anyway: // the JSONL backend's plaintext path is the composition's one legal encoding. const jsonl = find(rows, 'session-persistence-jsonl') if (jsonl !== undefined) { patches.push({ id: 'session-persistence-jsonl', config: { ...configOf(jsonl), compression: 'none' } }) } - return { patches, presetOverlay } + return { patches } } /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7d2f58ff7a..22b5b1e69f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -132,120 +132,80 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'agentPresets', - summary: 'Registry over the deployment\'s agent presets.', - description: 'Registry over the deployment\'s agent presets.\n\nDiscovery 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.', + summary: 'Registry of YAML-declared presets and the revisions live Agents retain.', + description: 'Registry of YAML-declared presets and the revisions live Agents retain.', methods: [ + { + signature: 'async register(definition: PresetDefinition): Promise<() => Promise>', + description: 'Register and eagerly load a definition; activation failure remains visible in the roster.', + parameters: [{ name: 'definition', description: 'Parsed configuration supplied by the declaring plugin.' }], + returns: 'Definition disposer after activation or its diagnostic settles; the declaring plugin owns it.', + }, { signature: 'async list(): Promise', - description: 'Every preset the configured roots currently supply.', + description: 'Read every declared preset, including activation failures.', parameters: [], - returns: 'the presets, first-root-wins per id.', + returns: 'Display metadata and loading diagnostics.', }, { signature: '@Remote(\'list\') async remoteExportList(): Promise', - description: 'The roster off the Host: list projected to path-free rows, with the policy-effective default marked, this deployment\'s authoring capability, and its mode-selection policy beside it.\n\nWhether a client can open a preset\'s directory is the Host\'s own opener capability, not a roster property — a caller needing both joins them.', + description: 'Read the selection roster and chooser policy.', parameters: [], - returns: 'the rows, authoring capability, and effective selection policy.', - }, - { - signature: 'async compositionInventory(): Promise', - description: 'Every preset\'s composition as flattened plugin rows, for plugin-listing surfaces beside the roster\'s own picker.\n\nA preset with a live standing mount answers from its newest generation\'s Loader entries — the composition new sessions join — even when the file behind it has since been edited into an unreadable state: the mount is what sessions actually run, so the broken verdict only applies to a preset nothing composed. One never composed since boot answers from its file, with `!!js` disabled gates evaluated against the Loader context so both answers reflect the same host. Reading never mounts: an unmounted preset is parsed, not composed, so listing a preset\'s plugins cannot activate them early. A composition that stopped reading between discovery\'s health verdict and this read is reported broken with the raced reason rather than dropped.', - parameters: [], - returns: 'one composition per roster preset, in roster order.', + returns: 'Current presets, default and chooser policy.', }, { signature: 'async resolve(id?: string): Promise', - description: 'Resolve one preset by id.\n\nA broken preset resolves — deleting one, reading one, and reporting one all need the row — and the mounting paths refuse it AFTER resolution through resolveMountable.', - parameters: [{ name: 'id', description: 'the preset id, or `undefined` for {@link defaultId}.' }], - returns: 'the resolved preset.', - throws: ['when no configured root supplies that id.'], + description: 'Resolve an identity without starting an Agent.', + parameters: [{ name: 'id', description: 'Explicit preset or the current default.' }], + returns: 'Current metadata, including failure when activation failed.', }, { - signature: 'async mount(agentCtx: Context, id?: string): Promise', - description: 'Compose one agent from a preset: ensure the preset\'s standing mount, then parent the agent\'s scope key to it so the mount\'s registrations and listeners cover this agent.\n\nCall from the agent factory\'s `setup(agentCtx)`; a rejection there rolls the agent creation back, so a broken preset never yields a half-composed session.', - parameters: [{ name: 'agentCtx', description: 'the agent\'s scope context.' }, { name: 'id', description: 'the preset id, or `undefined` for {@link defaultId}.' }], - returns: 'the preset that was composed, for the caller to record.', - throws: ['when the preset is unknown or its composition is unusable.'], + signature: 'async mount(ctx: Context, id?: string): Promise', + description: 'Bind an unpublished Agent to the current preset revision.', + parameters: [{ name: 'ctx', description: 'Agent context from its setup callback.' }, { name: 'id', description: 'Requested preset, or the default.' }], + returns: 'Bound preset identity.', }, { - signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', - description: 'Join one agent to the SAME standing composition another already runs on.\n\nThis 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).\n\nSynchronous, 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.\n\nA 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.', - parameters: [{ name: 'agentCtx', description: 'the joining agent\'s scope context.' }, { name: 'parentCtx', description: '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.'], + signature: 'composeFrom(ctx: Context, parent: Context): string | undefined', + description: 'Join a child to the exact revision retained by its parent.', + parameters: [{ name: 'ctx', description: 'Child Agent context.' }, { name: 'parent', description: 'Parent Agent context.' }], + returns: 'Inherited preset id, or undefined in a preset-free composition.', }, { - signature: 'composedPreset(agentCtx: Context): string | undefined', - description: 'The preset one live agent runs on.\n\nRead 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.', - parameters: [{ name: 'agentCtx', description: 'the agent\'s scope context.' }], - returns: 'the preset id, or undefined when the agent joined none.', - }, - { - signature: 'async read(id: string): Promise', - description: 'Read one preset\'s composition text.', - parameters: [{ name: 'id', description: 'the preset id.' }], - returns: 'the composition exactly as stored.', - throws: ['when no configured root supplies that id.'], - }, - { - signature: '@Remote(\'read\') async readDocument(agentPreset: string): Promise', - description: 'One preset\'s composition text with the roster row it belongs to.', - parameters: [{ name: 'agentPreset', description: 'the preset id.' }], - returns: 'the composition beside its trust and published metadata.', - throws: ['{RemoteError} `gateway/bad-request` for an empty id, or `agent-preset/not-found` when no configured root supplies it.'], - }, - { - signature: 'async copy(from: string, id: string, name?: string): Promise', - description: 'Create a locally authored preset by copying an existing one whole.\n\nCopy is the only authoring write. Composition text never crosses this seam: the source is named by id and its directory is copied as it stands, so the copy is exactly as loadable as its source and authoring grants no capability the roster did not already carry. The copy is NOT mounted to validate — a source that mounts today yields a copy that mounts today.', - parameters: [{ name: 'from', description: 'the preset the copy starts from; shipped presets are the primary source, so any trust is accepted.' }, { name: 'id', description: 'the new preset\'s id, which becomes its directory name.' }, { name: 'name', description: 'display name for the copy; absent falls back to the id.' }], - throws: ['when the source is unknown, the id is unusable or already taken, or the deployment configures no writable root.'], - }, - { - signature: '@Remote(\'copy\') async remoteExportCopy(from: string, id: string, name?: string): Promise', - description: 'Copy one preset through the Remote API.', - parameters: [{ name: 'from', description: 'the source preset id.' }, { name: 'id', description: 'the new preset id.' }, { name: 'name', description: 'the copy\'s optional display name.' }], - returns: 'once the copy is stored.', - throws: ['{RemoteError} with the corresponding stable preset code and details when the copy is refused.'], - }, - { - signature: 'async remove(id: string): Promise', - description: 'Delete a locally authored preset.', - parameters: [{ name: 'id', description: 'the preset id.' }], - throws: ['when the preset is unknown or ships with the deployment.'], - }, - { - signature: '@Remote(\'deletePreset\') async remoteExportDelete(id: string): Promise', - description: 'Delete one preset through the Remote API.', - parameters: [{ name: 'id', description: 'the preset id.' }], - returns: 'once the preset is deleted.', - throws: ['{RemoteError} with the corresponding stable preset code and details when deletion is refused.'], + signature: 'composedPreset(ctx: Context): string | undefined', + description: 'Read the preset a live Agent uses.', + parameters: [{ name: 'ctx', description: 'Agent context.' }], + returns: 'Its preset id, if bound.', }, { signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', - description: 'One agent\'s instance of a service its preset mounted.\n\nA preset publishes services behind `isolate` realms, which are invisible outside the group that declares them — including to the host. This is how a caller holding the agent reads one anyway: a request that is ABOUT a session but arrives from outside it, which is every browser RPC.\n\nRead addressing only. A host row that `inject`s a service cannot use this, because injection resolves before any session exists and has no agent to key by; such a service belongs on the host plane instead.', - parameters: [{ name: 'agent', description: 'the agent whose composition to look inside.' }, { name: 'name', description: 'the service name as the preset\'s rows resolve it.' }], - returns: 'the agent\'s instance, or undefined when its preset mounts none.', + description: 'Read a service supplied inside an Agent\'s isolated preset group.', + parameters: [{ name: 'agent', description: 'Agent whose composition is queried.' }, { name: 'name', description: 'Cordis service name.' }], + returns: 'The service, or undefined.', }, { - signature: 'async recompose(agentCtx: Context, id: string): Promise', - description: 'Re-link one agent to a different preset\'s standing composition.\n\nOnly valid while the agent has produced nothing: swapping tools mid conversation would leave logged tool calls the new composition cannot make. The CALLER owns that check — this method does not read session history.\n\nThe swap is a parent re-link, not an unmount: standing mounts are shared and permanent, so the old composition stays for its other agents and the new one is ensured BEFORE the link moves. An unknown or unusable preset therefore throws with the agent exactly as it was — there is no torn-down state to restore. The re-link runs through the binding this roster kept from the agent\'s mount — dsh-scope\'s only re-link authority. An agent that never composed one has nothing to re-link: the switch is then the agent\'s first bind, exactly a mount. A committed re-link emits `tools/change` because changing the parent scope changes the Agent\'s resolved tool set without adding or removing registry entries.', - parameters: [{ name: 'agentCtx', description: 'the agent\'s scope context.' }, { name: 'id', description: 'the preset to compose the agent from instead.' }], - returns: 'the preset now installed.', - throws: ['when the preset is unknown or its composition is unusable.'], + signature: 'async recompose(ctx: Context, id: string): Promise', + description: 'Rebind a blank Agent; the caller owns the blank-session check.', + parameters: [{ name: 'ctx', description: 'Agent context.' }, { name: 'id', description: 'Requested preset.' }], + returns: 'The bound identity.', }, { signature: '@Remote(\'select\') async select(agent: Agent, agentPreset: string): Promise', - description: 'Compose a blank session\'s agent from a different preset and record it.', - parameters: [{ name: 'agent', description: 'the session\'s live agent, resolved from the wire identity.' }, { name: 'agentPreset', description: 'the preset to compose the agent from instead.' }], - returns: 'the preset id that was recorded.', - throws: ['{RemoteError} with `gateway/bad-request`, `agent-preset/locked`, `agent-preset/not-found`, or `agent-preset/invalid` when refused.'], + description: 'Select a preset before a session starts its first turn.', + parameters: [{ name: 'agent', description: 'Target Agent.' }, { name: 'agentPreset', description: 'Requested identity.' }], + returns: 'Committed preset identity.', }, { - signature: 'async standingKeyFor(id?: string): Promise', - description: 'The standing scope key of one preset, for a host reader with no agent.\n\nA cold transcript read resolves tool presenters against the composition the session recorded, and the standing mount makes that possible without resuming anything: ensuring the mount composes plugins but starts no agent, no session, and no turn.', - parameters: [{ name: 'id', description: 'the preset id, or `undefined` for {@link defaultId}.' }], - returns: 'the standing scope key readers pass as a registry view scope.', - throws: ['when the preset is unknown or its composition is unusable.'], + signature: 'async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable>', + description: 'Read current registrations for cold transcript presentation.', + parameters: [{ name: 'id', description: 'Preset identity or the default.' }], + returns: 'A revision lease; dispose it after the scoped read completes.', + }, + { + signature: 'compositionInventory(): Promise', + description: 'Read plugin rows without creating an Agent.', + parameters: [], + returns: 'Current declaration metadata and activation states.', }, ], }, @@ -2351,12 +2311,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'provider writability, local-document presence, and one view per namespace.', throws: ['RemoteError when no settings provider is mounted.'], }, - { - signature: '@Remote canOpenAgentPresetDirectory(): boolean', - description: 'Report whether this deployment can open an authored Agent preset directory natively.', - parameters: [], - returns: 'true when the matching open operation is available.', - }, { signature: '@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise', description: 'Merge a patch into one namespace\'s stored user section.', @@ -2385,13 +2339,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'confirmation after the native opener accepts the document.', throws: ['RemoteError when no document exists, preparation fails, or opening fails.'], }, - { - signature: '@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise', - description: 'Open one user-authored Agent preset directory or return its path when no native opener exists.', - parameters: [{ name: 'agentPreset', description: 'preset id resolved against Host-owned roots.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }], - returns: 'an opened confirmation or the resolved directory for text display.', - throws: ['RemoteError when the preset is missing, read-only, invalid, or cannot be opened.'], - }, ], }, { @@ -4190,33 +4137,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n reasoningEffort?: ReasoningEffortId;\n maxTokens?: number;\n}', }, - { - name: 'AgentPreset', - declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', - }, { name: 'AgentPresetComposition', - declaration: 'export interface AgentPresetComposition {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly name?: string;\n readonly isDefault: boolean;\n readonly broken?: string;\n readonly rows: readonly AgentPresetCompositionRow[];\n}', + declaration: 'export interface AgentPresetComposition {\n readonly id: string;\n readonly name?: string;\n readonly isDefault: boolean;\n readonly broken?: string;\n readonly rows: readonly AgentPresetCompositionRow[];\n}', }, { name: 'AgentPresetCompositionRow', declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', }, - { - name: 'AgentPresetDirectoryOpenValue', - declaration: 'export type AgentPresetDirectoryOpenValue = {\n readonly opened: true;\n} | {\n readonly opened: false;\n readonly path: string;\n};', - }, - { - name: 'AgentPresetDocument', - declaration: 'export interface AgentPresetDocument {\n readonly agentPreset: string;\n readonly trust: PresetTrust;\n readonly content: string;\n readonly name?: string;\n readonly description?: string;\n}', - }, { name: 'AgentPresetRoster', - declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly authorable: boolean;\n readonly modeSelectionEnabled: boolean;\n}', + declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly modeSelectionEnabled: boolean;\n}', }, { name: 'AgentPresetRow', - declaration: 'export interface AgentPresetRow {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly isDefault: boolean;\n readonly name?: string;\n readonly description?: string;\n readonly broken?: string;\n}', + declaration: 'export interface AgentPresetRow {\n readonly id: string;\n readonly isDefault: boolean;\n readonly name?: string;\n readonly description?: string;\n readonly broken?: string;\n}', }, { name: 'AgentResolver', @@ -5614,6 +5549,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PrepareSessionOptions', declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n readonly eventState?: undefined;\n}) | RestoredSessionOptions;', }, + { + name: 'PresetDefinition', + declaration: 'export interface PresetDefinition {\n readonly id: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly plugins: readonly (Omit & {\n id?: string;\n disabled?: EntryOptions[\'disabled\'] | JsExpr;\n })[];\n}', + }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', @@ -5622,10 +5561,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, - { - name: 'PresetTrust', - declaration: 'export type PresetTrust = \'system\' | \'user\';', - }, { name: 'PreStepDecision', declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n startsRequestSeries?: true;\n};', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 0f4145b598..2916890a24 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/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/host/plugin-inventory/README.md -README.md: c882b6e00d16ec5b991ec0b6b5a45f7122b04609 -README.zh.md: 902952741332319a18b1cca7d683340af6f6004c +README.md: 8450339c1430b2ed1620004343f75d9cc21a0a19 +README.zh.md: 345bf367ce9fe85fdfc80994a09e3b2f26fdc0ce diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index c882b6e00d..8450339c14 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -102,7 +102,7 @@ These limits define what a point-in-time inventory cannot tell a client. They ar - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. - **No layer attribution or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane. -- **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty. +- **Presets appear only with a roster** — a deployment without `dsh-agent-preset-registry` serves Loader entries alone; the `agentPresets` field is absent rather than empty. ### Dev Note diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 9029527413..345bf367ce 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -102,7 +102,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产 - **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 - **不标识引入层,也不修改插件**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。 -- **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。 +- **预设仅随 roster 出现**——未装 `dsh-agent-preset-registry` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。 ### 开发备注 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 31a504845d..f618d47924 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -48,13 +48,13 @@ }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-presets": { + "@deepseek-ai/dsh-agent-preset-registry": { "optional": true } }, @@ -62,7 +62,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-package-manifest": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 0600f51536..b122998414 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -3,7 +3,7 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' // Type-only: the optional agent-preset roster resolved through `ctx.get`. -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-app-boot' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index b97ee6e8ee..b6ebc2d3ce 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -52,8 +52,6 @@ export interface AgentPresetPluginRow { export interface AgentPresetPluginGroup { /** Stable preset id. */ readonly id: string - /** Whether the deployment ships the preset or the user owns it. */ - readonly trust: 'system' | 'user' /** Display name the preset published; a reader falls back to the id. */ readonly name?: string /** Whether a session naming no preset composes this one. */ diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index f55ca87a38..667d8ac66f 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -7,7 +7,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' import { PluginPackages, readPluginMeta, type RuntimeResolution } from '@deepseek-ai/dsh-app-boot' import Loader from '@deepseek-ai/cordis-plugin-loader' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' -import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' +import type { AgentPresetRegistry } from '@deepseek-ai/dsh-agent-preset-registry' import PluginInventoryGateway from '../src/index.ts' const contexts: Context[] = [] @@ -109,18 +109,18 @@ describe('PluginInventoryGateway', () => { await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {}) ctx.provide('agentPresets', { compositionInventory: async () => [{ - id: 'local', trust: 'system', isDefault: true, + id: 'local', isDefault: true, rows: [ { entryId: 'feature', moduleName: 'local-plugin/feature', enabled: false }, { entryId: null, moduleName: 'local-plugin/private', enabled: false }, ], }], - } as Partial as never) + } as Partial as never) expect(await inventory.list()).toEqual({ entries: [], agentPresets: [{ - id: 'local', trust: 'system', isDefault: true, + id: 'local', isDefault: true, rows: [ { entryId: 'feature', moduleName: 'local-plugin/feature', enabled: false, fiberPhase: null, @@ -202,7 +202,6 @@ describe('PluginInventoryGateway', () => { compositionInventory: async () => [ { id: 'standard', - trust: 'system', name: '标准模式', isDefault: true, rows: [ @@ -210,15 +209,14 @@ describe('PluginInventoryGateway', () => { { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x' }, ], }, - { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition file is missing', rows: [] }, + { id: 'damaged', isDefault: false, broken: 'the composition file is missing', rows: [] }, ], - } as Partial as never) + } as Partial as never) const snapshot = await inventory.list() expect(snapshot.agentPresets).toEqual([ { id: 'standard', - trust: 'system', name: '标准模式', isDefault: true, rows: [ @@ -226,7 +224,7 @@ describe('PluginInventoryGateway', () => { { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x', fiberPhase: null }, ], }, - { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition file is missing', rows: [] }, + { id: 'damaged', isDefault: false, broken: 'the composition file is missing', rows: [] }, ]) }) }) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 75b1f99127..776338fc6f 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../../vendor/loader" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../util/brand" diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 7799c7013d..8be9721018 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -39,12 +39,12 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-presets": { + "@deepseek-ai/dsh-agent-preset-registry": { "optional": true } }, @@ -54,7 +54,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/llm/plugin-package-inventory-deepseek/src/index.ts b/packages/llm/plugin-package-inventory-deepseek/src/index.ts index 83068d9d61..c6f91be7e9 100644 --- a/packages/llm/plugin-package-inventory-deepseek/src/index.ts +++ b/packages/llm/plugin-package-inventory-deepseek/src/index.ts @@ -16,7 +16,7 @@ import type { Entry, EntryTree } from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-app-boot' import type { DeepSeekPluginPackageIdentity, DeepSeekPluginPackageInventoryExtension } from './types.ts' import type {} from './types.ts' @@ -166,7 +166,7 @@ async function collectActivePluginPackages( if (agent !== undefined) { // The optional peer is loaded only when its service is present. Its existing // mount query keeps Loader internals off the public AgentPresets service. - const { standingMountFor } = await import('@deepseek-ai/dsh-agent-presets') + const { standingMountFor } = await import('@deepseek-ai/dsh-agent-preset-registry') const presetTree = standingMountFor(agent.ctx)?.tree // PresetTree deliberately resolves its root bare rows from the harness; // nested ordinary includes retain their own tree base. diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index ef42c27139..74bfdd647b 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -10,7 +10,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createScope } from '@deepseek-ai/dsh-scope' -import AgentPresets, { mountPreset } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets from '@deepseek-ai/dsh-agent-preset-registry' import { PluginPackages } from '@deepseek-ai/dsh-app-boot' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import * as PluginInventory from '../src/index.ts' @@ -50,7 +50,7 @@ async function harness( ctx.loader.builtins.include = Include await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeShippedRoot: false, includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'fixture' }) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) const inventory = enabled === undefined ? ctx.plugin(PluginInventory) @@ -219,20 +219,12 @@ describe('DeepSeek plugin package inventory', () => { ]) }) - it('mirrors the standing preset bare-package override instead of its local node_modules', async () => { + it('resolves a declared preset plugin from its owning composition', async () => { const { ctx, root } = await harness() await packagePlugin(root, 'node_modules/preset-only', { name: 'preset-only', version: '4.0.0' }) - const presetDir = join(root, 'preset') - await mkdir(presetDir, { recursive: true }) - await packagePlugin(presetDir, 'node_modules/preset-only', { name: 'preset-only', version: '9.0.0' }) - const composition = join(presetDir, 'agent.cordis.yml') - await writeFile(composition, '- id: preset-only\n name: preset-only/plugin.mjs\n') - - const standingKey = {} - const standing = createScope(ctx, standingKey) - await mountPreset(standing.ctx, { id: 'fixture', trust: 'user', path: composition }) - const agentKey = {} - const agentScope = createScope(ctx, agentKey, { parent: standingKey }) + await ctx.agentPresets.register({ id: 'fixture', plugins: [{ id: 'preset-only', name: 'preset-only/plugin.mjs' }] }) + const agentScope = createScope(ctx, {}) + await ctx.agentPresets.mount(agentScope.ctx) const id = SessionId('preset-agent') const agent = { id, ctx: agentScope.ctx, session: { id } } as unknown as Agent await ctx.agents.register(agent) diff --git a/packages/llm/plugin-package-inventory-deepseek/tsconfig.json b/packages/llm/plugin-package-inventory-deepseek/tsconfig.json index 76699c3da7..17fb979e15 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tsconfig.json +++ b/packages/llm/plugin-package-inventory-deepseek/tsconfig.json @@ -30,7 +30,7 @@ "path": "../../core/session" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../deepseek-llm-api-extensions" diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index d6b3ff53a0..bd82e1ace2 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: efb1c44e2f9a0a5a0222178c16784a62c1107404 -README.zh.md: f7dc7d06f4cde02a736568f22c4e99c27f1eaf07 +README.md: 1a68961007ab04d7266964760f3518d24a595c0c +README.zh.md: 4d9a096f2bf3a9689be5735ec751d1e8eeaf34f0 diff --git a/packages/preset/README.md b/packages/preset/README.md index efb1c44e2f..1a68961007 100644 --- a/packages/preset/README.md +++ b/packages/preset/README.md @@ -1,5 +1,5 @@ --- -description: "The preset group map: per-session agent composition from preset files, for users and maintainers navigating the group." +description: "This group declares Agent capabilities through ordinary Cordis configuration and manages selection and runtime revisions. The Host shares the Agent loop; each Agent sees the tools, prompts and skills of its selected revision." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The preset group provides per-session agent composition: an agent preset is a directory holding one `agent.cordis.yml`, and a session composed from a preset runs that preset's tools, prompt sections, and skills while every other session keeps its own. `agent-presets` owns the roster — discovery over configured roots plus the harness home, the guarded per-agent mount, and copy-only authoring — and `persona` supplies the composable row that lets a preset change an agent's identity and not only its tools. Together they let one process run several differently composed agents at once. +This group declares Agent capabilities through ordinary Cordis configuration and manages selection and runtime revisions. The Host shares the Agent loop; each Agent sees the tools, prompts and skills of its selected revision. ## Table of Contents @@ -17,29 +17,21 @@ The preset group provides per-session agent composition: an agent preset is a di - [Related documentation](#related-documentation) - [Dev Note](#dev-note) ------ - ## Packages | Package | Role | ctx key | |---|---|---| -| [`agent-presets`](agent-presets/README.md) | Preset roster, discovery over trusted and user roots, per-agent composition, copy-only authoring | `ctx.agentPresets` | -| [`persona`](persona/README.md) | The composable persona row a preset mounts to shadow or replace the deployment persona | — | - ------ +| [agent-preset-registry](agent-preset-registry/README.md) | Selection, revision retention and profile editing | `ctx.agentPresets` | +| [agent-preset](agent-preset/README.md) | Declarative child plugins and metadata | — | +| [persona](persona/README.md) | Composable Agent persona | — | ## Related documentation -- [`AgentPresets` reference](../../docs/subsystems/core.md#ctxagentpresets--agentpresets) — discovery, mounting, inheritance, and recomposition. -- [Scope subsystem](../../docs/subsystems/scope.md) — scope keys and the parent chain the mount uses to join agents. -- [System prompt subsystem](../../docs/subsystems/system-prompt.md) — how preset prompt sections register and assemble. -- [Per-session agent presets note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md) — design rationale and alternatives. - -The presets the deployment ships live in [`agent-presets/presets/`](agent-presets/presets) — one directory per preset, and that directory listing is the roster; naming them here too would be a second list to keep in step. - ------ +- [Scope](../../docs/subsystems/scope.md) +- [Cordis](../../docs/cordis-primer.md) +- [Agent preset](../../.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md) ## Dev Note diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index f7dc7d06f4..4d9a096f2b 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -1,5 +1,5 @@ --- -description: "preset 组地图:按会话从 preset 文件组装 agent,供浏览本组的用户与维护者阅读。" +description: "本组通过普通 Cordis 声明定义 Agent 能力,并管理会话选择和运行时代际。Host 共享 Agent loop;各 Agent 使用自己选定代际的工具、提示词和技能。" kind: "package-group" --- @@ -9,43 +9,35 @@ kind: "package-group" ## 概述 -preset 组提供按会话的 agent(智能体)组装:agent preset 是一个目录,内含一份 `agent.cordis.yml`;从 preset 组装的会话会使用该 preset 的工具、提示词段落与 skill(技能),而其他会话仍各自使用自己的工具、提示词段落与 skill。`agent-presets` 拥有名单——对已配置根目录与 harness home 的发现、受防护的按 agent 挂载,以及仅通过复制创建 preset 的方式——`persona` 则提供可组装的行,让 preset 不止能改变 agent 的工具,也能改变它的身份。两者合起来让一个进程可以同时运行多个组装方式不同的 agent。 +本组通过普通 Cordis 声明定义 Agent 能力,并管理会话选择和运行时代际。Host 共享 Agent loop;各 Agent 使用自己选定代际的工具、提示词和技能。 ## 目录 - [包](#packages) - [相关文档](#related-documentation) -- [开发备注](#dev-note) - ------ +- [开发笔记](#dev-note) ## 包 -| 包 | 职责 | ctx 键 | +| Package | Role | ctx key | |---|---|---| -| [`agent-presets`](agent-presets/README.zh.md) | preset 名单、对受信任根目录与用户根目录的发现、按 agent 组装、仅通过复制创建 preset | `ctx.agentPresets` | -| [`persona`](persona/README.zh.md) | preset 挂载的可组装人设行,用于遮蔽或替换部署级人设 | — | - ------ +| [agent-preset-registry](agent-preset-registry/README.zh.md) | 选择、代际保留和 profile 编辑 | `ctx.agentPresets` | +| [agent-preset](agent-preset/README.zh.md) | 声明式子插件和元数据 | — | +| [persona](persona/README.zh.md) | 可组装的 Agent 人设 | — | ## 相关文档 -- [`AgentPresets` 参考](../../docs/subsystems/core.zh.md#ctxagentpresets--agentpresets)——发现、挂载、继承与重组。 -- [Scope 子系统](../../docs/subsystems/scope.zh.md)——scope key,以及挂载接入 agent 时所用的父链。 -- [系统提示词子系统](../../docs/subsystems/system-prompt.zh.md)——preset 提示词段落如何注册与组装。 -- [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md)——设计理由与备选方案。 - -部署交付的 preset 位于 [`agent-presets/presets/`](agent-presets/presets)——一个 preset 一个目录,那份目录列表就是名单;在这里再列一遍只会多出一份需要同步的名单。 - ------ +- [Scope](../../docs/subsystems/scope.zh.md) +- [Cordis](../../docs/cordis-primer.zh.md) +- [Agent preset](../../.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md) -## 开发备注 +## 开发笔记
-维护者的工作上下文——点击展开 +维护者工作上下文 — 点击展开 无。 diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml new file mode 100644 index 0000000000..7033a03980 --- /dev/null +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-preset-registry/README.md +README.md: 9a4c02abb87f4529292133caad3a7d4381715820 +README.zh.md: 7419f79f31375b9742f9db972322d3906ccbbe3d diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md new file mode 100644 index 0000000000..9a4c02abb8 --- /dev/null +++ b/packages/preset/agent-preset-registry/README.md @@ -0,0 +1,107 @@ +--- +description: "Choose an Agent’s tools, prompt sections and skills through declarative presets. One process can run several compositions. Failed definitions remain visible, while existing Agents retain the composition they already use." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-agent-preset-registry + +English | [中文](README.zh.md) + +## Summary + +Choose an Agent’s tools, prompt sections and skills through declarative presets. One process can run several compositions. Failed definitions remain visible, while existing Agents retain the composition they already use. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +### Minimal configuration + +```yaml +- id: agent-preset-registry + name: '@deepseek-ai/dsh-agent-preset-registry' + config: + default: standard +- id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + plugins: [] +``` + +| Field | Default | Meaning | +|---|---|---| +| `default` | required | Preset ID used when none is requested | + +The Web definitions come from the `dsh-web-app` bundle. Definitions are ordinary plugin rows; the registry neither scans directories nor accepts preset paths. The `agent-presets` settings namespace retains the user default and `modeSelectionEnabled`; hiding the chooser uses the deployment default. + +The registry writes no declarations. A new preset or an override of a shipped one is a bundle patch: an `insert` of a `@deepseek-ai/dsh-agent-preset` row, or a patch keyed by that row’s id, installed into the profile with `plugin_manager`; Creator mode authors such bundles in conversation. + + +## Understand the implementation + +
+Implementation internals — click to expand + +Each declaration eagerly creates a registry-owned scope and an in-memory Loader tree. Updating or removing a declaration retires its previous revision. Agents, children and temporary historical reads retain references; releasing the final reference disposes the retired tree. Plugin registrations inherit the preset scope, and the Agent scope’s parent link controls visibility. The Host continues to share the Agent loop. + +Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. + +| File | Responsibility | +|---|---| +| [index.ts](src/index.ts) | Registration, revisions and Agent bindings | +| [mount.ts](src/mount.ts) | Scoped plugin trees and activation auditing | + +
+ + +## Further Exploration + +- [Scope](../../core/scope/README.md) — Registration isolation. +- [Agent](../../core/agent/README.md) — Session runtime. +- [Cordis](../../../docs/cordis-primer.md) — Plugin configuration and lifecycle. + + +## Model Experience + +### Preset selection + +#### What the model sees + +Nothing directly: the selected preset’s `plugins` own the model-visible tools and prompt sections. + +#### Token effect + +None from this package; each mounted `plugins` row declares its own tools and sections. + +#### KV Cache effect + +Existing Agents retain their plugins and prompts. New Agents build their prefixes from the current definition. + +## Known Limitations and Deferred Work + + + +- Presets are not security sandboxes: YAML and plugins can execute Host code. A user override replaces the complete child list and does not automatically merge future changes to the builtin list. Old revision implementations are not retained across process restarts. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** The companion checks services leaked globally after activation and Agents addressing a model without joining a configured preset. diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md new file mode 100644 index 0000000000..7419f79f31 --- /dev/null +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -0,0 +1,107 @@ +--- +description: "通过声明式 preset 选择 Agent 的工具、提示词和技能。同一进程可以运行多种组合。配置失败会显示在列表中;现有 Agent 保留已经使用的组合。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-agent-preset-registry + +[English](README.md) | 中文 + +## 概述 + +通过声明式 preset 选择 Agent 的工具、提示词和技能。同一进程可以运行多种组合。配置失败会显示在列表中;现有 Agent 保留已经使用的组合。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +### 最小配置 + +```yaml +- id: agent-preset-registry + name: '@deepseek-ai/dsh-agent-preset-registry' + config: + default: standard +- id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + plugins: [] +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `default` | 必填 | 未显式指定时使用的 preset ID | + +Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注册表不扫描目录,也不接受 preset 路径。`agent-presets` settings 命名空间保留用户默认值和 `modeSelectionEnabled`;隐藏选择器时使用部署默认值。 + +注册表不写入任何声明。新建 preset 或覆盖内置 preset 都是 bundle 补丁:插入一行 `@deepseek-ai/dsh-agent-preset`,或按该行 id 写覆盖补丁,再用 `plugin_manager` 安装到 profile;创造模式在对话中编写这类 bundle。 + + +## 理解实现 + +
+实现细节 — 点击展开 + +每个声明在启动时创建注册表拥有的 scope 和内存 Loader 树。更新或移除声明会让旧代际退役;Agent、子 Agent 和临时历史读取各自持有引用,最后一个引用释放后才销毁插件树。插件注册继承 preset scope,Agent scope 的父链接决定可见性;Agent loop 仍由宿主共享。 + +激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。 + +| 文件 | 职责 | +|---|---| +| [index.ts](src/index.ts) | 注册、代际与 Agent 绑定 | +| [mount.ts](src/mount.ts) | 隔离插件树与激活审计 | + +
+ + +## 延伸阅读 + +- [Scope](../../core/scope/README.zh.md) — 注册隔离。 +- [Agent](../../core/agent/README.zh.md) — 会话运行时。 +- [Cordis](../../../docs/cordis-primer.zh.md) — 插件配置与生命周期。 + + +## 模型体验 + +### Preset 选择 + +#### 模型看到的内容 + +没有直接内容:所选 preset 的 `plugins` 拥有模型可见的工具和提示词片段。 + +#### Token 影响 + +本包自身没有;每个挂载的 `plugins` 行声明自己的工具和片段。 + +#### KV Cache effect + +已有 Agent 保留插件和提示词。新 Agent 从当前定义构建前缀。 + +## 已知限制与待办 + + + +- preset 不是安全沙箱:YAML 及插件可以执行 Host 代码。用户覆盖替换完整子插件列表,不自动合并内置列表的未来更新。进程退出后不保存旧代际的插件实现。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
+ +**运行时不变量:** companion 检查挂载后泄漏到全局的服务,以及模型请求前尚未绑定 preset 的 Agent。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-preset-registry/package.json similarity index 85% rename from packages/preset/agent-presets/package.json rename to packages/preset/agent-preset-registry/package.json index 345e1805f2..b3250887ad 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-preset-registry/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-agent-presets", - "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", + "name": "@deepseek-ai/dsh-agent-preset-registry", + "description": "Declarative Agent preset registry and profile-backed editing", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/preset/agent-presets" + "directory": "packages/preset/agent-preset-registry" }, "type": "module", "main": "lib/index.js", @@ -44,7 +44,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "presets", "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/typert.host.js", @@ -54,12 +53,9 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -70,7 +66,6 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "js-yaml": "^4.1.0", "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, @@ -84,7 +79,6 @@ "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -93,6 +87,11 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-hmr": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-agent-preset": "workspace:^" } } diff --git a/packages/preset/agent-presets/src/composition-inventory.ts b/packages/preset/agent-preset-registry/src/composition-inventory.ts similarity index 71% rename from packages/preset/agent-presets/src/composition-inventory.ts rename to packages/preset/agent-preset-registry/src/composition-inventory.ts index 101cc34ed1..53333e53d2 100644 --- a/packages/preset/agent-presets/src/composition-inventory.ts +++ b/packages/preset/agent-preset-registry/src/composition-inventory.ts @@ -1,22 +1,7 @@ -/** - * Structured composition reads for plugin-listing surfaces: the plugin rows - * each preset names, with each row's effective enablement. A preset with a - * live standing mount answers from that mount's Loader entries — evaluated - * `disabled`, real root-fiber states; a preset no session has composed since - * boot answers from its composition file, with `!!js` disabled expressions - * evaluated through the caller-supplied Loader evaluator so the file answer - * matches the decision a mount on this host would make. A row whose - * expression the evaluator refuses stays `'conditional'`. - * @module @deepseek-ai/dsh-agent-presets/composition-inventory - */ - -import { readFile } from 'node:fs/promises' -import { load } from 'js-yaml' +/** Plugin inventory from declared configuration or an activated revision. */ import type { FiberState } from '@deepseek-ai/cordis' import { isJsExpr, type EntryTree } from '@deepseek-ai/cordis-plugin-loader' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import { entryListProblem } from './discovery.ts' -import type { PresetTrust } from './preset.ts' +import { entryListProblem } from './definition.ts' /** * Effective enablement of one composition row: a literal or evaluated @@ -35,8 +20,8 @@ export type DisabledExpressionEvaluator = (expression: string) => unknown /** One plugin row a preset composition names. */ export interface AgentPresetCompositionRow { /** - * The Loader-tree entry id when read from a live mount, else the id the - * composition file declares; null when the file row declares none. + * Entry id relative to the preset's Loader tree, or the declared id before + * activation; null when an unmounted row declares none. */ readonly entryId: string | null /** Module specifier the row names. */ @@ -53,8 +38,6 @@ export interface AgentPresetCompositionRow { export interface AgentPresetComposition { /** Stable preset id. */ readonly id: string - /** Whether the deployment ships the preset or the user owns it. */ - readonly trust: PresetTrust /** Display name the preset published. */ readonly name?: string /** Whether a session naming no preset composes this one. */ @@ -147,29 +130,15 @@ function flattenRows( } } -/** - * Plugin rows of one composition file, for a preset with no live mount. - * - * Parsed with the Loader's own dialect ({@link entryListSchema}), so the rows - * reported are the rows a mount would start from. A file that stopped reading - * as a composition — discovery judged the preset healthy moments earlier, so - * only an edit racing this read gets here — answers as broken with the raced - * reason rather than dropping the rows silently. - * @param path - absolute path of the composition file. - * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. - * @returns flattened rows in composition order, or why they cannot be read. +/** Flatten declared child plugins for diagnostics before a successful activation. + * @param rows Parsed child entries. + * @param evaluateExpression Loader-context evaluator for disabled expressions. + * @returns Flattened entries or a configuration diagnostic. */ -export async function fileComposition( - path: string, +export function definitionComposition( + rows: unknown, evaluateExpression: DisabledExpressionEvaluator, -): Promise<{ rows: AgentPresetCompositionRow[] } | { broken: string }> { - let rows: unknown - try { - rows = load(await readFile(path, 'utf8'), { schema: entryListSchema }) - } catch (error) { - /* v8 ignore next -- fs and js-yaml throw Errors for every failure here; the fallback keeps a hostile value readable */ - return { broken: error instanceof Error ? error.message : String(error) } - } +): { rows: AgentPresetCompositionRow[] } | { broken: string } { const problem = entryListProblem(rows) if (problem !== undefined) return { broken: problem } const found: AgentPresetCompositionRow[] = [] @@ -184,10 +153,12 @@ export async function fileComposition( */ export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionRow[] { const found: AgentPresetCompositionRow[] = [] + const owner = tree.ctx.fiber.entry + const prefix = owner === undefined ? '' : `${owner.id}:` for (const entry of tree.entries()) { if (entry.options.group) continue found.push({ - entryId: entry.id, + entryId: entry.id.slice(prefix.length), moduleName: entry.options.name, enabled: !entry.disabled, ...isJsExpr(entry.options.disabled) ? { condition: entry.options.disabled.__jsExpr } : {}, diff --git a/packages/preset/agent-preset-registry/src/definition.ts b/packages/preset/agent-preset-registry/src/definition.ts new file mode 100644 index 0000000000..548ad5623b --- /dev/null +++ b/packages/preset/agent-preset-registry/src/definition.ts @@ -0,0 +1,39 @@ +/** Declarative preset configuration and YAML validation. */ +import type { EntryOptions, JsExpr } from '@deepseek-ai/cordis-plugin-loader' + +/** Identity, display fields and child Cordis plugins of one preset. */ +export interface PresetDefinition { + readonly id: string + readonly name?: string + readonly description?: string + readonly order?: number + readonly plugins: readonly (Omit & { id?: string; disabled?: EntryOptions['disabled'] | JsExpr })[] +} + +/** Validate a parsed Cordis entry list, including nested groups. + * @param rows Parsed YAML value. + * @param at Diagnostic prefix. + * @returns The first invalid row, or undefined. + */ +export function entryListProblem(rows: unknown, at = ''): string | undefined { + if (!Array.isArray(rows)) { + return at === '' + ? 'the composition must be a top-level list of plugin rows' + : `group ${at} must hold a list of plugin rows` + } + for (const [index, row] of rows.entries()) { + const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}` + if (typeof row !== 'object' || row === null || Array.isArray(row)) { + return `${label} is not a plugin row (expected a map with a "name")` + } + const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown } + if (typeof name !== 'string' || name === '') { + return `${label} names no plugin (a "name" string is required)` + } + if (group === true) { + const nested = entryListProblem(config, label) + if (nested !== undefined) return nested + } + } + return undefined +} diff --git a/packages/preset/agent-presets/src/display.ts b/packages/preset/agent-preset-registry/src/display.ts similarity index 80% rename from packages/preset/agent-presets/src/display.ts rename to packages/preset/agent-preset-registry/src/display.ts index 55653cb7d7..15585dde76 100644 --- a/packages/preset/agent-presets/src/display.ts +++ b/packages/preset/agent-preset-registry/src/display.ts @@ -4,7 +4,7 @@ * user-authored metadata is never translated. A pure fold with no imports, so * browser bundles inline it and the Host uses the same single home for which * shipped id carries which copy key. - * @module @deepseek-ai/dsh-agent-presets/display + * @module @deepseek-ai/dsh-agent-preset-registry/display */ /** Dictionary keys carrying one shipped preset's display copy. */ @@ -18,8 +18,6 @@ export type BuiltInPresetCopyKey = export interface PresetDisplaySource { /** Stable preset id. */ readonly id: string - /** Whether the deployment ships the preset or the user owns it. */ - readonly trust: 'system' | 'user' /** Unlocalized name published by the preset. */ readonly name?: string /** Unlocalized description published by the preset. */ @@ -46,17 +44,27 @@ const BUILT_IN_PRESET_KEYS: Readonly>> cordis: { name: 'presetCordisName', description: 'presetCordisDescription' }, } +/** + * Whether a roster row is one of the shipped presets whose copy the dictionaries carry. + * A shipped preset publishes no `name`; a declaration that names itself owns its copy. + * @param preset - roster row. + * @returns true for a shipped preset id without a published name. + */ +export function isBuiltInPreset(preset: PresetDisplaySource): boolean { + return preset.name === undefined && BUILT_IN_PRESET_KEYS[preset.id] !== undefined +} + /** * Resolve preset display copy without making user-authored metadata translatable. * @param preset - roster row whose copy is being rendered. * @param t - active locale lookup covering {@link BuiltInPresetCopyKey}. - * @returns localized copy for a known shipped preset, otherwise file metadata. + * @returns localized copy for a known shipped preset, otherwise declaration metadata. */ export function presetDisplayText( preset: PresetDisplaySource, t: (key: BuiltInPresetCopyKey) => string, ): PresetDisplayText { - const keys = preset.trust === 'system' ? BUILT_IN_PRESET_KEYS[preset.id] : undefined + const keys = isBuiltInPreset(preset) ? BUILT_IN_PRESET_KEYS[preset.id] : undefined if (keys !== undefined) return { name: t(keys.name), description: t(keys.description) } return { name: preset.name ?? preset.id, diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts new file mode 100644 index 0000000000..04303eff56 --- /dev/null +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -0,0 +1,364 @@ +/** Declarative Agent capability sets, activation and session binding. */ +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SettingsScope } from '@deepseek-ai/dsh-settings' +import type {} from '@deepseek-ai/dsh-tools' +import type { AgentPresetRoster } from './types.ts' +import { entryListProblem, type PresetDefinition } from './definition.ts' +import type { AgentPreset, Config } from './preset.ts' +import { agentPresetProjectionDefinition } from './session.ts' +import { auditRows, mountPreset, standingMountFor, serviceForAgent, type PresetMount } from './mount.ts' +import { definitionComposition, mountedCompositionRows, type AgentPresetComposition } from './composition-inventory.ts' + +export { agentPresetProjectionDefinition } from './session.ts' +export { entryListProblem, type PresetDefinition } from './definition.ts' +export { auditRows, livePresetMounts, leakedServices, serviceForAgent, standingMountFor, type PresetMount, type RowAudit } from './mount.ts' +export type { AgentPreset, Config } from './preset.ts' + +/** User-selected default and visibility of the new-session chooser. */ +export interface AgentPresetSettings { + default: string + modeSelectionEnabled: boolean +} +/** Accepted settings fields. */ +export const AgentPresetSettingsSchema: z = z.object({ + default: z.string(), modeSelectionEnabled: z.boolean(), +}) + +declare module '@deepseek-ai/cordis' { + interface Context { + agentPresets: AgentPresetRegistry + } +} + +interface Generation { + scope: Scope + key: ScopeKey + mount: PresetMount + users: number + retired: boolean +} +interface Definition { + config: PresetDefinition + context: Context + ready: Promise + generation?: Generation + /** Mount failure; unset while a tree is mounted, whose rows {@link AgentPresetRegistry.diagnostic} re-audits on read. */ + broken?: string +} +interface Binding { + parent: ScopeParentBinding + generation: Generation +} + +/** Registry of YAML-declared presets and the revisions live Agents retain. */ +export class AgentPresetRegistry extends TypertRemoteService { + static inject = ['loader', 'sessionProjections'] + static Config: z = z.object({ default: z.string().required() }) + private readonly owner: Context + private readonly definitions = new Map() + private readonly generations = new Map() + private readonly bindings = new WeakMap() + private readonly switches = new Map>() + private settings: SettingsScope | undefined + + constructor(ctx: Context, public config: Config) { + super(ctx, 'agentPresets') + this.owner = ctx + ctx.sessionProjections.register(agentPresetProjectionDefinition) + ctx.inject(['settings'], (settingsCtx) => { + this.settings = settingsCtx.settings.register('agent-presets', AgentPresetSettingsSchema, { + base: { default: config.default, modeSelectionEnabled: true }, + }) + settingsCtx.effect(() => () => { this.settings = undefined }, 'agent-presets.settings') + }) + ctx.on('session/event', (session, event) => { + if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) + }) + } + + /** Default preset for a subsequently created session. */ + get defaultId(): string { return this.policy().defaultId } + + private policy(): { enabled: boolean; defaultId: string } { + const settings = this.settings?.get() + const enabled = settings?.modeSelectionEnabled ?? true + return { enabled, defaultId: enabled ? settings?.default ?? this.config.default : this.config.default } + } + + /** Register and eagerly load a definition; activation failure remains visible in the roster. + * @param definition Parsed configuration supplied by the declaring plugin. + * @returns Definition disposer after activation or its diagnostic settles; the declaring plugin owns it. + */ + async register(definition: PresetDefinition): Promise<() => Promise> { + const context = this.ctx + if (!definition.id.trim()) throw new Error('Preset id must not be empty') + if (this.definitions.has(definition.id)) throw new Error(`Duplicate agent preset: ${definition.id}`) + const record: Definition = { config: definition, context, ready: Promise.resolve() } + this.definitions.set(definition.id, record) + let disposed = false + const unregister = async (): Promise => { + if (disposed) return + disposed = true + this.definitions.delete(definition.id) + await record.ready + if (record.generation !== undefined) { + record.generation.retired = true + await this.collect(record.generation) + } + } + record.ready = this.activate(record) + await record.ready + return unregister + } + + private async activate(record: Definition): Promise { + const key = {} + const scope = createScope(this.owner, key) + try { + const problem = entryListProblem(record.config.plugins) + if (problem !== undefined) throw new Error(problem) + const context = scope.ctx.extend({ baseUrl: record.context.baseUrl }) + const mount = await mountPreset(context, record.config.id, record.config.plugins) + const generation: Generation = { scope, key, mount, users: 0, retired: false } + this.generations.set(key, generation) + record.generation = generation + } catch (error) { + record.broken = (error as Error).message + this.owner.logger.warn(`agent preset ${record.config.id}: ${record.broken}`) + await scope.dispose() + } + } + + /** + * Current activation diagnostic of a definition. + * + * A mount failure is final. A mounted tree is re-audited on every read: a + * row waiting for a Host service activates by itself once that provider + * finishes, so the audit waits for the Host Loader tree to settle before + * reporting the row as unusable. Callers therefore must not run inside a + * Host row's own activation, which the settlement would wait on. + * @param record - the definition to audit. + * @returns one line per unusable row, or undefined when the definition is usable. + */ + private async diagnostic(record: Definition): Promise { + await record.ready + if (record.generation === undefined) return record.broken + const tree = record.generation.mount.tree + let audit = await auditRows(tree) + if (audit.pending.length > 0) { + await this.owner.loader.await() + audit = await auditRows(tree) + } + const lines = [...audit.failed, ...audit.pending] + return lines.length === 0 ? undefined : lines.join('\n') + } + + private async collect(generation: Generation): Promise { + if (!generation.retired || generation.users !== 0) return + this.generations.delete(generation.key) + await generation.scope.dispose() + } + + /** Read every declared preset, including activation failures. + * @returns Display metadata and loading diagnostics. + */ + async list(): Promise { + const rows = await Promise.all([...this.definitions.values()].map(async (record) => { + const broken = await this.diagnostic(record) + return { + id: record.config.id, + ...(record.config.name === undefined ? {} : { name: record.config.name }), + ...(record.config.description === undefined ? {} : { description: record.config.description }), + ...(record.config.order === undefined ? {} : { order: record.config.order }), + ...(broken === undefined ? {} : { broken }), + } + })) + return rows.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.id.localeCompare(b.id)) + } + + /** Read the selection roster and chooser policy. + * @returns Current presets, default and chooser policy. + */ + @Remote('list') + async remoteExportList(): Promise { + const policy = this.policy() + return { presets: (await this.list()).map(row => ({ ...row, isDefault: row.id === policy.defaultId })), + modeSelectionEnabled: policy.enabled } + } + + /** Resolve an identity without starting an Agent. + * @param id Explicit preset or the current default. + * @returns Current metadata, including failure when activation failed. + */ + async resolve(id?: string): Promise { + const wanted = id ?? this.defaultId + const record = this.definitions.get(wanted) + if (record === undefined) throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, + { agentPreset: wanted, available: [...this.definitions.keys()] }) + const broken = await this.diagnostic(record) + return { id: wanted, ...(broken === undefined ? {} : { broken }) } + } + + private async retain(id?: string): Promise { + const wanted = id ?? this.defaultId + while (true) { + const record = this.definitions.get(wanted) + if (record === undefined) throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, + { agentPreset: wanted, available: [...this.definitions.keys()] }) + const broken = await this.diagnostic(record) + if (this.definitions.get(wanted) !== record) continue + const generation = record.generation + if (broken !== undefined || generation === undefined) { + const reason = broken as string + throw new RemoteError('agent-preset/invalid', reason, { agentPreset: wanted, reason }) + } + generation.users++ + return generation + } + } + + private async bind(ctx: Context, generation: Generation): Promise { + const key = scopeOf(ctx) + if (key === undefined) throw new Error('Agent preset binding requires a scoped context') + const binding = this.bindings.get(key) + if (binding?.generation === generation) return + if (binding !== undefined) { + binding.parent.rebind(generation.key) + const old = binding.generation + generation.users++ + binding.generation = generation + old.users-- + await this.collect(old) + } else this.join(ctx, key, generation) + } + + private join(ctx: Context, key: ScopeKey, generation: Generation): void { + const binding = { parent: bindScopeParent(key, generation.key), generation } + generation.users++ + this.bindings.set(key, binding) + ctx.effect(() => async () => { + this.bindings.delete(key) + binding.generation.users-- + await this.collect(binding.generation) + }, 'agent-preset.binding') + } + + /** Bind an unpublished Agent to the current preset revision. + * @param ctx Agent context from its setup callback. + * @param id Requested preset, or the default. + * @returns Bound preset identity. + */ + async mount(ctx: Context, id?: string): Promise { + const generation = await this.retain(id) + try { + await this.bind(ctx, generation) + return { id: generation.mount.presetId } + } finally { + generation.users-- + await this.collect(generation) + } + } + + /** Join a child to the exact revision retained by its parent. + * @param ctx Child Agent context. + * @param parent Parent Agent context. + * @returns Inherited preset id, or undefined in a preset-free composition. + */ + composeFrom(ctx: Context, parent: Context): string | undefined { + const mounted = standingMountFor(parent) + if (mounted === undefined) return undefined + const generation = this.generations.get(mounted.key) + if (generation === undefined) throw new Error('Parent preset revision is unavailable') + // A child has no existing binding, so this path has no asynchronous cleanup. + const key = scopeOf(ctx) + if (key === undefined) throw new Error('Child preset binding requires a scope') + if (this.bindings.has(key)) throw new Error('Child already joined a preset') + this.join(ctx, key, generation) + return mounted.presetId + } + + /** Read the preset a live Agent uses. + * @param ctx Agent context. + * @returns Its preset id, if bound. + */ + composedPreset(ctx: Context): string | undefined { return standingMountFor(ctx)?.presetId } + + /** Read a service supplied inside an Agent's isolated preset group. + * @param agent Agent whose composition is queried. + * @param name Cordis service name. + * @returns The service, or undefined. + */ + serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { + return serviceForAgent(this.owner, agent, name) + } + + /** Rebind a blank Agent; the caller owns the blank-session check. + * @param ctx Agent context. + * @param id Requested preset. + * @returns The bound identity. + */ + async recompose(ctx: Context, id: string): Promise { + const preset = await this.mount(ctx, id) + try { this.owner.emit('tools/change') } + catch (error) { this.owner.logger.warn(`Preset tools observer: ${String(error)}`) } + return preset + } + + /** Select a preset before a session starts its first turn. + * @param agent Target Agent. + * @param agentPreset Requested identity. + * @returns Committed preset identity. + */ + @Remote('select') + async select(agent: Agent, agentPreset: string): Promise { + const turn = (this.switches.get(agent.id) ?? Promise.resolve()).then(async () => { + const boundary = this.owner.sessionProjections.stateOf(agent.session, 'turnBoundary') + if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { + throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset }) + } + const preset = await this.recompose(agent.ctx, agentPreset) + agent.session.append('agent-preset/selected', { agentPreset: preset.id }) + return preset.id + }) + const guard = turn.catch(() => undefined) + this.switches.set(agent.id, guard) + try { return await turn } finally { + if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id) + } + } + + /** Read current registrations for cold transcript presentation. + * @param id Preset identity or the default. + * @returns A revision lease; dispose it after the scoped read completes. + */ + async acquireScope(id?: string): Promise<{ key: ScopeKey } & AsyncDisposable> { + const generation = await this.retain(id) + let disposed = false + return { key: generation.key, [Symbol.asyncDispose]: async () => { + if (disposed) return + disposed = true + generation.users-- + await this.collect(generation) + } } + } + + /** Read plugin rows without creating an Agent. + * @returns Current declaration metadata and activation states. + */ + compositionInventory(): Promise { + return Promise.all([...this.definitions.values()].map(async (record) => { + const { id, name, description } = record.config + const broken = await this.diagnostic(record) + const read = definitionComposition(record.config.plugins, () => { throw new Error('Inactive definition') }) + return { id, ...(name === undefined ? {} : { name }), ...(description === undefined ? {} : { description }), + isDefault: id === this.defaultId, + ...(broken === undefined ? {} : { broken }), + rows: record.generation === undefined ? ('rows' in read ? read.rows : []) : mountedCompositionRows(record.generation.mount.tree) } + })) + } +} +export default AgentPresetRegistry diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-preset-registry/src/invariant.ts similarity index 69% rename from packages/preset/agent-presets/src/invariant.ts rename to packages/preset/agent-preset-registry/src/invariant.ts index 81cfd834ab..d4eb9c2b1b 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-preset-registry/src/invariant.ts @@ -1,6 +1,6 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-agent-presets`. - * @module @deepseek-ai/dsh-agent-presets/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-preset-registry`. + * @module @deepseek-ai/dsh-agent-preset-registry/invariant */ import type { Context } from '@deepseek-ai/cordis' @@ -12,9 +12,9 @@ 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. -import { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-preset-registry' -const PACKAGE_NAME = '@deepseek-ai/dsh-agent-presets' +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-preset-registry' /** Cordis companion plugin name. */ export const name = 'agent-presets-invariant' @@ -43,24 +43,12 @@ const install: InvariantInstaller = (ctx, fail) => { } }, { global: true }) - // 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. - // - // 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. + // Bare Agents are valid until model assembly. Cold scoped reads carry no + // Agent and do not require a session binding. ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') const agent = context.agent - if (presets !== undefined && presets.roots.length > 0 + if (presets !== undefined && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` diff --git a/packages/preset/agent-preset-registry/src/mount.ts b/packages/preset/agent-preset-registry/src/mount.ts new file mode 100644 index 0000000000..bbfa8026cb --- /dev/null +++ b/packages/preset/agent-preset-registry/src/mount.ts @@ -0,0 +1,268 @@ +/** Runtime plugin trees shared by Agents selecting one preset revision. */ +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { EntryTree, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { PresetDefinition } from './definition.ts' +import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' + +/** In-memory Loader tree; only the profile configuration editor persists definitions. */ +class PresetTree extends EntryTree { + constructor(ctx: Context) { + const owner = ctx.fiber.entry + const subtree = owner?.subtree + const subgroup = owner?.subgroup + super(ctx) + if (owner !== undefined) { + if (subtree === undefined) delete owner.subtree + else owner.subtree = subtree + if (subgroup === undefined) delete owner.subgroup + else owner.subgroup = subgroup + } + } + + override write(): void {} +} + +/** One live revision shared by Agents and scoped readers. */ +export interface PresetMount { + /** The preset the subtree was composed from. */ + readonly presetId: string + /** The mounted subtree's fiber. */ + readonly fiber: Fiber + /** Loader entry tree whose active rows form this standing composition. */ + readonly tree: EntryTree + /** The standing scope key agents are parented to (undefined only in torn-down records). */ + readonly key: ScopeKey | undefined +} + +const mounts = new Set() + +/** + * Every preset composition retained by the registry. + * + * The record set is module state and therefore spans every Cordis runtime in + * the process; a reader that serves one runtime passes that runtime's root + * fiber so another runtime mounting the same preset id (a second embedded + * app, a test's second harness) never answers for it. + * @param within - when present, only mounts inside this fiber's subtree. + * @returns the live mounts. + */ +export function livePresetMounts(within?: Fiber): PresetMount[] { + const all = [...mounts] + return within === undefined ? all : all.filter(mount => withinFiber(mount.fiber, within)) +} + +/** + * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. + * + * Membership is object identity. `uid` looks like a cheaper key but is a + * per-registry counter, so fibers in two different roots collide on it and a + * subtree in one runtime would be blamed for a service published in another. + * @param fiber - the fiber to locate. + * @param root - the subtree root to test membership against. + * @returns true when `fiber` belongs to `root`'s subtree. + */ +function withinFiber(fiber: Fiber, root: Fiber): boolean { + let current = fiber + while (true) { + if (current === root) return true + const parent = current.parent.fiber + if (parent === current) return false + current = parent + } +} + +/** + * Service names the mounted subtree published into the root realm. + * + * A provider without an `isolate` realm stores its implementation under the + * root's symbol for that name, which is exactly the comparison below; a + * provider inside an `isolate` realm stores under a realm-private symbol and + * is correctly absent here. + * @param ctx - any context of the runtime whose service store is inspected. + * @param mount - the mounted subtree's fiber. + * @returns the leaked service names in lexical order. + */ +export function leakedServices(ctx: Context, mount: Fiber): string[] { + const store = ctx.reflect.store + const rootIsolate = ctx.root[Context.isolate] + const leaked: string[] = [] + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than + clearing it, so an own symbol always resolves; the guard exists only + because the store's index signature is optional. */ + if (impl === undefined) continue + if (!withinFiber(impl.fiber, mount)) continue + if (rootIsolate[impl.name] === key) leaked.push(impl.name) + } + 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. + * + * Preset revisions publish services behind an `isolate` realm. Browser RPCs + * hold the Agent but resolve outside that realm, so they locate its revision + * through the Agent's scope parent. + * + * Ownership is the same relation {@link leakedServices} reads, inverted: there + * it names implementations a subtree published into the ROOT realm, here it + * names the one this subtree published anywhere. Fiber membership is object + * identity for the reason stated on {@link withinFiber}. + * + * This is read addressing for a caller that already holds the agent. It is not + * a general host handle on a session's internals: a host row that `inject`s a + * service cannot use it, because injection resolves before any session exists + * and has no agent to key by — such a service belongs on the host plane. + * @param ctx - any context of the runtime whose service store is inspected. + * @param agent - the agent whose mounted composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +export function serviceForAgent( + ctx: Context, + agent: { ctx: Context }, + name: K, +): Context[K] | undefined { + const mount = standingMountFor(agent.ctx) + if (mount === undefined) return undefined + const store = ctx.reflect.store + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ + if (impl === undefined) continue + if (impl.name !== name) continue + if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K] + } + return undefined +} + +/** Rows that did not reach a usable state, each rendered as one diagnostic line. */ +export interface RowAudit { + /** Rows that never started or whose import or activation rejected. */ + readonly failed: string[] + /** + * Rows waiting for a service the composition does not supply. A Host + * provider still activating completes such a row later; only a settled Host + * tree tells that case from a genuinely missing service. + */ + readonly pending: string[] +} + +/** + * Audit the rows of a mounted subtree. + * + * Wait for the subtree, then report import failures, activation failures, and + * rows waiting for services the composition does not supply. + * @param tree - the mounted subtree. + * @returns failed and pending rows, both empty when every enabled row is usable. + */ +export async function auditRows(tree: EntryTree): Promise { + await tree.await() + const failed: string[] = [] + const pending: string[] = [] + for (const entry of tree.entries()) { + if (entry.disabled) continue + const fiber = entry.fiber + if (fiber === undefined) { + failed.push(`${entry.options.id} (${entry.options.name}): never started`) + continue + } + try { + await fiber.await() + } catch (error) { + const detail = mountDetail(error) + failed.push(`${entry.options.id} (${entry.options.name}): ${detail}`) + continue + } + const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) + if (missing.length > 0) { + pending.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) + } + } + return { failed, pending } +} + +/** + * The causes of `error` whose detail its own message does not already carry. + * + * Aggregate errors carry separate member messages. A wrapper can preserve the + * aggregate as its cause without including those messages in its own text. + * @param error - the failure to read branches from. + * @returns the branches to render beneath `error.message`, possibly empty. + */ +function detailBranches(error: Error): readonly unknown[] { + if (error instanceof AggregateError) return error.errors + return error.cause instanceof AggregateError ? error.cause.errors : [] +} + +/** + * The reportable text of a mount failure. + * + * A plugin may reject with an aggregate or wrap one as its cause. Include its + * member messages beneath the row diagnostic so each failure is visible. + * @param error - the value the mount rejected with. + * @returns a single-line-per-cause description. + */ +function mountDetail(error: unknown): string { + if (!(error instanceof Error)) return String(error) + const branches = detailBranches(error) + if (branches.length === 0) return error.message + return [ + error.message, + ...branches.map(branch => `- ${mountDetail(branch).replaceAll('\n', '\n ')}`), + ].join('\n') +} + +/** Load and audit one revision under its registry-owned scope. + * + * Failed rows and root-realm service leaks reject the mount. Rows waiting for + * a Host service stay mounted: they activate by themselves once the provider + * finishes, and the registry re-audits them after the Host tree settles. + * @param ctx Scope context inheriting the declaring Loader's resolution base. + * @param id Preset identity. + * @param plugins Declared Cordis entry list. + * @returns The live tree; scope disposal owns its teardown. + */ +export async function mountPreset(ctx: Context, id: string, plugins: PresetDefinition['plugins']): Promise { + if (scopeOf(ctx) === undefined) throw new Error('agent-preset: mounting requires a scope') + await ctx.fiber.await() + const tree = new PresetTree(ctx) + ctx.effect(() => () =>{ tree.root.stop() }, 'agent-preset.tree') + await tree.root.update(structuredClone(plugins) as EntryOptions[]) + const audit = await auditRows(tree) + const leaked = leakedServices(ctx, ctx.fiber) + if (audit.failed.length > 0) throw new Error(audit.failed.join('\n')) + if (leaked.length > 0) throw new Error(`Preset services require isolate realms: ${leaked.join(', ')}`) + const mount = { presetId: id, fiber: ctx.fiber, tree, key: scopeOf(ctx) } + mounts.add(mount) + ctx.effect(() => () => { mounts.delete(mount) }, 'agent-preset.mount') + return mount +} diff --git a/packages/preset/agent-preset-registry/src/preset.ts b/packages/preset/agent-preset-registry/src/preset.ts new file mode 100644 index 0000000000..b2bdab1198 --- /dev/null +++ b/packages/preset/agent-preset-registry/src/preset.ts @@ -0,0 +1,15 @@ +/** Public preset roster and selection configuration. */ +/** One declared preset and its current activation failure, if any. */ +export interface AgentPreset { + readonly id: string + readonly name?: string + readonly description?: string + readonly order?: number + readonly broken?: string +} + +/** Registry selection policy. */ +export interface Config { + /** Deployment default when the caller omits a preset. */ + default: string +} diff --git a/packages/preset/agent-presets/src/session.ts b/packages/preset/agent-preset-registry/src/session.ts similarity index 96% rename from packages/preset/agent-presets/src/session.ts rename to packages/preset/agent-preset-registry/src/session.ts index 61df969967..9a6e779cc1 100644 --- a/packages/preset/agent-presets/src/session.ts +++ b/packages/preset/agent-preset-registry/src/session.ts @@ -11,7 +11,7 @@ * * Reconstruction reads the `agentPreset` Session projection, never the header * alone. - * @module @deepseek-ai/dsh-agent-presets/session + * @module @deepseek-ai/dsh-agent-preset-registry/session */ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-preset-registry/src/types.ts similarity index 61% rename from packages/preset/agent-presets/src/types.ts rename to packages/preset/agent-preset-registry/src/types.ts index a2bfd68800..4a73dc09b9 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-preset-registry/src/types.ts @@ -1,18 +1,13 @@ /** Client-safe payloads and event declarations owned by the agent-preset domain. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { PresetTrust } from './preset.ts' -export type { PresetTrust } from './preset.ts' /** - * One roster row as a client reads it. Path-free: a preset is addressed by id - * everywhere off the Host, and the composition's location is the Host's own. + * One declared preset as a client reads it. */ export interface AgentPresetRow { /** Stable identifier; also the label's fallback. */ readonly id: string - /** Trust of the root this preset was discovered under. */ - readonly trust: PresetTrust /** Whether a session naming no preset composes this one. */ readonly isDefault: boolean /** Display name the preset published. */ @@ -23,43 +18,25 @@ export interface AgentPresetRow { readonly broken?: string } -/** The roster one deployment currently supplies, with its authoring capability. */ +/** The roster one deployment currently supplies. */ export interface AgentPresetRoster { - /** Every preset the configured roots supply, first-root-wins per id. */ + /** Every current declaration, including activation failures. */ readonly presets: readonly AgentPresetRow[] - /** Whether this deployment has a root locally authored presets go to. */ - readonly authorable: boolean /** Whether visible mode selection is enabled for unnamed new sessions. */ readonly modeSelectionEnabled: boolean } declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { - /** No configured root supplies the requested id. */ + /** No declaration supplies the requested identity. */ 'agent-preset/not-found': { readonly agentPreset: string; readonly available: readonly string[] } /** The id is unusable, already taken, or its composition cannot be installed. */ 'agent-preset/invalid': { readonly agentPreset: string; readonly reason: string } - /** The preset ships with the deployment and is not the user's to change. */ - 'agent-preset/read-only': { readonly agentPreset: string; readonly reason: string } /** The session's conversation has started, so its composition is fixed. */ 'agent-preset/locked': { readonly sessionId: SessionId; readonly agentPreset: string } } } -/** One preset's composition text beside the row it belongs to. */ -export interface AgentPresetDocument { - /** The preset the composition belongs to. */ - readonly agentPreset: string - /** Trust of the root this preset was discovered under. */ - readonly trust: PresetTrust - /** The composition exactly as stored. */ - readonly content: string - /** Display name the preset published. */ - readonly name?: string - /** One sentence on what this preset is for. */ - readonly description?: string -} - declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { agentPreset: string | null diff --git a/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts b/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts new file mode 100644 index 0000000000..b9db854c1e --- /dev/null +++ b/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { definitionComposition } from '../src/composition-inventory.ts' + +it('reports evaluated and unresolved conditions with ancestor enablement', () => { + const js = (code: string) => ({ __jsExpr: code }) + const read = definitionComposition([ + { name: 'enabled', id: 'enabled-id' }, + { name: 'off', disabled: true }, + { name: 'on', disabled: js('false') }, + { name: 'conditional', disabled: js('unknownVariable') }, + { name: 'group', group: true, disabled: js('unknownVariable'), config: [ + { name: 'child' }, { name: 'disabled-child', disabled: true }, + ] }, + { name: 'off-group', group: true, disabled: true, config: [{ name: 'buried' }] }, + ], expression => evaluate({}, expression)) + expect(read).toEqual({ rows: [ + { entryId: 'enabled-id', moduleName: 'enabled', enabled: true }, + { entryId: null, moduleName: 'off', enabled: false }, + { entryId: null, moduleName: 'on', enabled: true, condition: 'false' }, + { entryId: null, moduleName: 'conditional', enabled: 'conditional', condition: 'unknownVariable' }, + { entryId: null, moduleName: 'child', enabled: 'conditional' }, + { entryId: null, moduleName: 'disabled-child', enabled: false }, + { entryId: null, moduleName: 'buried', enabled: false }, + ] }) + expect(definitionComposition([{ group: true }], () => false)).toHaveProperty('broken') +}) diff --git a/packages/preset/agent-presets/tests/display.spec.ts b/packages/preset/agent-preset-registry/tests/display.spec.ts similarity index 68% rename from packages/preset/agent-presets/tests/display.spec.ts rename to packages/preset/agent-preset-registry/tests/display.spec.ts index b8f1a7f8b0..5233b4305b 100644 --- a/packages/preset/agent-presets/tests/display.spec.ts +++ b/packages/preset/agent-preset-registry/tests/display.spec.ts @@ -10,21 +10,21 @@ const t = (key: BuiltInPresetCopyKey): string => `t:${key}` describe('presetDisplayText', () => { it('resolves a shipped preset through its dictionary keys', () => { - expect(presetDisplayText({ id: 'standard', trust: 'system', name: '标准模式' }, t)).toEqual({ + expect(presetDisplayText({ id: 'standard' }, t)).toEqual({ name: 't:presetStandardName', description: 't:presetStandardDescription', }) }) it('keeps user-authored metadata untranslated', () => { - expect(presetDisplayText({ id: 'mine', trust: 'user', name: '我的模式', description: '自述' }, t)) + expect(presetDisplayText({ id: 'mine', name: '我的模式', description: '自述' }, t)) .toEqual({ name: '我的模式', description: '自述' }) }) it('falls back to the id for a preset publishing no metadata', () => { // A system id outside the shipped set behaves like authored metadata: // there is no dictionary copy to resolve. - expect(presetDisplayText({ id: 'future', trust: 'system' }, t)).toEqual({ name: 'future' }) - expect(presetDisplayText({ id: 'bare', trust: 'user' }, t)).toEqual({ name: 'bare' }) + expect(presetDisplayText({ id: 'future' }, t)).toEqual({ name: 'future' }) + expect(presetDisplayText({ id: 'bare' }, t)).toEqual({ name: 'bare' }) }) }) diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/contribute.js similarity index 100% rename from packages/preset/agent-presets/tests/fixtures/plugins/contribute.js rename to packages/preset/agent-preset-registry/tests/fixtures/plugins/contribute.js diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/global-service.js similarity index 100% rename from packages/preset/agent-presets/tests/fixtures/plugins/global-service.js rename to packages/preset/agent-preset-registry/tests/fixtures/plugins/global-service.js diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/late-service.js similarity index 100% rename from packages/preset/agent-presets/tests/fixtures/plugins/late-service.js rename to packages/preset/agent-preset-registry/tests/fixtures/plugins/late-service.js diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/needs-missing.js similarity index 100% rename from packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js rename to packages/preset/agent-preset-registry/tests/fixtures/plugins/needs-missing.js diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/self-dispose.js similarity index 100% rename from packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js rename to packages/preset/agent-preset-registry/tests/fixtures/plugins/self-dispose.js diff --git a/packages/preset/agent-preset-registry/tests/fixtures/plugins/throws.js b/packages/preset/agent-preset-registry/tests/fixtures/plugins/throws.js new file mode 100644 index 0000000000..69facf74db --- /dev/null +++ b/packages/preset/agent-preset-registry/tests/fixtures/plugins/throws.js @@ -0,0 +1,6 @@ +// A loadable plugin whose activation rejects with its configured message. +export const name = 'throws' + +export function apply(_ctx, config) { + throw new Error(config.message) +} diff --git a/packages/preset/agent-preset-registry/tests/harness.ts b/packages/preset/agent-preset-registry/tests/harness.ts new file mode 100644 index 0000000000..6be58b887a --- /dev/null +++ b/packages/preset/agent-preset-registry/tests/harness.ts @@ -0,0 +1,47 @@ +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Group from '@deepseek-ai/cordis-plugin-group' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import AgentPresets, { type PresetDefinition } from '../src/index.ts' + +export const plugin = (name: string): string => new URL(`./fixtures/plugins/${name}.js`, import.meta.url).href +export const contribution = (tool: string): PresetDefinition => ({ id: tool, plugins: [{ name: plugin('contribute'), config: { tool } }] }) +export async function harness(): Promise { + const ctx = new Context() + ctx.baseUrl = new URL('./fixtures/', import.meta.url).href + await ctx.plugin(Loader) + ctx.loader.builtins.group = Group + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'standard' }) + return ctx +} +export async function declare(ctx: Context, config: PresetDefinition) { + return await ctx.plugin({ + inject: ['agentPresets'], + async* apply(child: Context) { yield await child.agentPresets.register(config) }, + }) +} +export async function agentOn(ctx: Context, id: string, presetId?: string) { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: async (agentCtx: Context) => { await ctx.agentPresets.mount(agentCtx, presetId) }, + }) + return handle.agent +} + +export async function currentKey(ctx: Context, id?: string) { + await using lease = await ctx.agentPresets.acquireScope(id) + return lease.key +} diff --git a/packages/preset/agent-preset-registry/tests/invariant.spec.ts b/packages/preset/agent-preset-registry/tests/invariant.spec.ts new file mode 100644 index 0000000000..53362400fc --- /dev/null +++ b/packages/preset/agent-preset-registry/tests/invariant.spec.ts @@ -0,0 +1,40 @@ +import { expect, it, onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Invariants from '@deepseek-ai/dsh-invariants' +import { assembleContextFor } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { harness, declare, contribution, agentOn } from './harness.ts' +import { livePresetMounts } from '../src/index.ts' +import * as invariant from '../src/invariant.ts' + +it('rejects model use by an unjoined Agent but permits Host and cold-scope reads', async () => { + const ctx = await harness() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Invariants) + await ctx.plugin(invariant) + await declare(ctx, contribution('standard')) + const handle = await ctx.agents.create({ sessionId: SessionId('unjoined') }) + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).rejects.toThrow('without joining') + const agent = await agentOn(ctx, 'joined') + await expect(ctx.systemPrompt.assemble(assembleContextFor(agent))).resolves.toBeDefined() + await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined() + await using lease = await ctx.agentPresets.acquireScope() + await expect(ctx.systemPrompt.assemble({ scope: lease.key })).resolves.toBeDefined() +}) + +it('detects a late global service without confusing independent runtimes', async () => { + const first = await harness() + const second = await harness() + onTestFinished(async () => { await first.fiber.dispose(); await second.fiber.dispose() }) + await first.plugin(Invariants) + await first.plugin(invariant) + let publish!: () => void + first.loader.builtins.late = { apply(ctx: Context) { + publish = () => { ctx.effect(() => ctx.reflect.provide('latePresetService', {})) } + } } + await declare(first, { id: 'standard', plugins: [{ name: 'cordis:late' }] }) + await declare(second, contribution('standard')) + expect(livePresetMounts(first.fiber)).toHaveLength(1) + expect(livePresetMounts(second.fiber)).toHaveLength(1) + expect(() =>{ publish() }).toThrow('published process-global') +}) diff --git a/packages/preset/agent-preset-registry/tests/mount.spec.ts b/packages/preset/agent-preset-registry/tests/mount.spec.ts new file mode 100644 index 0000000000..7199bda34a --- /dev/null +++ b/packages/preset/agent-preset-registry/tests/mount.spec.ts @@ -0,0 +1,72 @@ +import { Context } from '@deepseek-ai/cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import { expect, it, onTestFinished } from 'vitest' +import { harness, declare } from './harness.ts' +import { auditRows, mountPreset, livePresetMounts } from '../src/mount.ts' +import { mountedCompositionRows } from '../src/composition-inventory.ts' + +it('preserves individual causes of import and plugin failures', async () => { + const ctx = await harness() + onTestFinished(() => ctx.fiber.dispose()) + ctx.loader.builtins.stringFailure = () => { throw 'string rejection' } + ctx.loader.builtins.aggregateFailure = () => { + throw new AggregateError([ + new Error('first member'), + new Error('wrapped member', { cause: new AggregateError(['nested member'], 'nested aggregate') }), + ], 'aggregate rejection') + } + await ctx.loader.root.update([ + { id: 'missing', name: 'cordis:missingBuiltin' }, + { id: 'disabled', name: 'cordis:missingBuiltin', disabled: true }, + { id: 'string', name: 'cordis:stringFailure' }, + { id: 'aggregate', name: 'cordis:aggregateFailure' }, + ]) + expect(await auditRows(ctx.loader)).toEqual({ failed: [ + 'missing (cordis:missingBuiltin): never started', + 'string (cordis:stringFailure): string rejection', + 'aggregate (cordis:aggregateFailure): aggregate rejection\n- first member\n- wrapped member\n - nested member', + ], pending: [] }) + await expect(mountPreset(ctx, 'unscoped', [])).rejects.toThrow('requires a scope') +}) + +it('does not replace the declaring Loader entry subtree or persist runtime tree changes', async () => { + const ctx = await harness() + onTestFinished(() => ctx.fiber.dispose()) + const scopes: ReturnType[] = [] + ctx.loader.builtins.parent = { inject: ['loader'], async apply(owner: Context) { + const entry = owner.fiber.entry! + const previousTree = entry.subtree + const previousGroup = entry.subgroup + const scope = createScope(owner, {}) + scopes.push(scope) + const mount = await mountPreset(scope.ctx, 'owned', [{ id: 'child', name: 'missing', disabled: true }]) + expect([...mount.tree.entries()][0]!.id).toBe('parent:child') + expect(mountedCompositionRows(mount.tree)[0]!.entryId).toBe('child') + expect(entry.subtree).toBe(previousTree) + expect(entry.subgroup).toBe(previousGroup) + mount.tree.write() + entry.subtree = mount.tree + entry.subgroup = mount.tree.root + const other = createScope(owner, {}) + scopes.push(other) + await mountPreset(other.ctx, 'owned-again', []) + expect(entry.subtree).toBe(mount.tree) + expect(entry.subgroup).toBe(mount.tree.root) + delete entry.subtree + delete entry.subgroup + } } + await ctx.loader.root.update([{ id: 'parent', name: 'cordis:parent' }]) + expect(await auditRows(ctx.loader)).toEqual({ failed: [], pending: [] }) + expect(scopes).toHaveLength(2) + for (const scope of scopes) await scope.dispose() +}) + +it('reports grouped and conditional plugin rows from the activated tree', async () => { + const ctx = await harness() + onTestFinished(() => ctx.fiber.dispose()) + await declare(ctx, { id: 'standard', plugins: [{ name: 'cordis:group', group: true, config: [ + { id: 'off', name: 'missing', disabled: { __jsExpr: 'true' } }, + ] }] }) + const tree = livePresetMounts(ctx.fiber)[0]!.tree + expect(mountedCompositionRows(tree)).toEqual([{ entryId: 'off', moduleName: 'missing', enabled: false, condition: 'true' }]) +}) diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts new file mode 100644 index 0000000000..76190cf66a --- /dev/null +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createScope } from '@deepseek-ai/dsh-scope' +import { assembleContextFor } from '@deepseek-ai/dsh-agent' +import { entryListProblem, livePresetMounts } from '../src/index.ts' +import { currentKey, harness, declare, contribution, agentOn } from './harness.ts' +import type { Context } from '@deepseek-ai/cordis' + +const contexts: Context[] = [] +afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() }) +async function setup() { const ctx = await harness(); contexts.push(ctx); return ctx } + +describe('declarative preset revisions', () => { + it('eagerly scopes tool and prompt registrations and shares one revision across Agents', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await declare(ctx, contribution('minimal')) + expect(livePresetMounts(ctx.fiber)).toHaveLength(2) + const first = await agentOn(ctx, 'first') + const second = await agentOn(ctx, 'second', 'minimal') + const third = await agentOn(ctx, 'third') + expect(ctx.tools.schemas(first).map(row => row.name)).toEqual(['standard']) + expect(ctx.tools.schemas(second).map(row => row.name)).toEqual(['minimal']) + expect(ctx.tools.schemas(third).map(row => row.name)).toEqual(['standard']) + expect(ctx.tools.schemas()).toEqual([]) + const prompt = await ctx.systemPrompt.assemble(assembleContextFor(first)) + expect(prompt.sections.map(row => row.name)).toContain('preset:standard') + expect(prompt.sections.map(row => row.name)).not.toContain('preset:minimal') + expect(livePresetMounts(ctx.fiber)).toHaveLength(2) + }) + + it('retains a replaced revision for existing scopes and their children, then releases it', async () => { + const ctx = await setup() + const old = await declare(ctx, contribution('standard')) + const agent = createScope(ctx, {}) + await ctx.agentPresets.mount(agent.ctx) + const oldKey = await currentKey(ctx) + await old.dispose() + await declare(ctx, { ...contribution('replacement'), id: 'standard' }) + expect(await currentKey(ctx)).not.toBe(oldKey) + expect(livePresetMounts(ctx.fiber)).toHaveLength(2) + const child = createScope(ctx, {}) + expect(ctx.agentPresets.composeFrom(child.ctx, agent.ctx)).toBe('standard') + await agent.dispose() + expect(livePresetMounts(ctx.fiber)).toHaveLength(2) + await child.dispose() + expect(livePresetMounts(ctx.fiber)).toHaveLength(1) + const fresh = await agentOn(ctx, 'fresh') + expect(ctx.tools.schemas(fresh).map(row => row.name)).toEqual(['replacement']) + }) + + it('removes definitions without revoking live bindings', async () => { + const ctx = await setup() + const definition = await declare(ctx, contribution('standard')) + const agent = createScope(ctx, {}) + await ctx.agentPresets.mount(agent.ctx) + await definition.dispose() + expect(await ctx.agentPresets.list()).toEqual([]) + await expect(ctx.agentPresets.resolve()).rejects.toThrow('Unknown agent preset') + expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard') + await agent.dispose() + expect(livePresetMounts(ctx.fiber)).toEqual([]) + }) + + it('reports a failed definition while allowing healthy definitions and the host to start', async () => { + const ctx = await setup() + await declare(ctx, { id: 'broken', plugins: [{ name: 'missing-preset-plugin-for-test' }] }) + await declare(ctx, contribution('standard')) + expect(await ctx.agentPresets.resolve('broken')).toMatchObject({ id: 'broken', broken: expect.any(String) as string }) + const scope = createScope(ctx, {}) + await expect(ctx.agentPresets.mount(scope.ctx, 'broken')).rejects.toThrow() + expect(await ctx.agentPresets.mount(scope.ctx)).toEqual({ id: 'standard' }) + const roster = await ctx.agentPresets.remoteExportList() + expect(roster.modeSelectionEnabled).toBe(true) + expect(roster.presets.find(row => row.id === 'standard')?.isDefault).toBe(true) + }) + + it('rejects duplicate IDs without disposing the first definition', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await expect(declare(ctx, contribution('standard'))).rejects.toThrow('Duplicate agent preset') + expect(await ctx.agentPresets.resolve()).toEqual({ id: 'standard' }) + }) + + it('rebinds a blank scope and preserves a child inheriting its former revision', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await declare(ctx, contribution('minimal')) + const scope = createScope(ctx, {}) + await ctx.agentPresets.mount(scope.ctx) + await ctx.agentPresets.mount(scope.ctx) + const child = createScope(ctx, {}) + ctx.agentPresets.composeFrom(child.ctx, scope.ctx) + await ctx.agentPresets.recompose(scope.ctx, 'minimal') + expect(ctx.agentPresets.composedPreset(scope.ctx)).toBe('minimal') + expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('standard') + expect(ctx.agentPresets.composeFrom(createScope(ctx, {}).ctx, ctx)).toBeUndefined() + }) + + it('logs selection and rejects preset changes after the first turn', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await declare(ctx, contribution('minimal')) + const agent = await agentOn(ctx, 'selection') + expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') + expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + agent.session.append('turn/start', { turn: 1 }) + await expect(ctx.agentPresets.select(agent, 'standard')).rejects.toThrow('already started') + }) + + it('inventories active and disabled child entries and declared display metadata', async () => { + const ctx = await setup() + await declare(ctx, { ...contribution('standard'), name: 'Standard', description: 'General', order: 2 }) + await declare(ctx, { id: 'empty', order: 1, plugins: [{ name: 'missing', disabled: true }] }) + expect((await ctx.agentPresets.list()).map(row => row.id)).toEqual(['empty', 'standard']) + expect(await ctx.agentPresets.compositionInventory()).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'standard', name: 'Standard', isDefault: true, rows: expect.any(Array) as unknown[] }), + expect.objectContaining({ id: 'empty', rows: [expect.objectContaining({ moduleName: 'missing', enabled: false })] }), + ])) + }) +}) + +it('retains a retired revision for an in-flight cold read', async () => { + const ctx = await setup() + const definition = await declare(ctx, contribution('standard')) + const lease = await ctx.agentPresets.acquireScope() + await definition.dispose() + expect(livePresetMounts(ctx.fiber)).toHaveLength(1) + await lease[Symbol.asyncDispose]() + await lease[Symbol.asyncDispose]() + expect(livePresetMounts(ctx.fiber)).toEqual([]) +}) + +it.each([['throws', 'refused'], ['global-service', 'require isolate realms']])('contains a %s plugin activation failure', async (name, reason) => { + const ctx = await setup() + const { plugin } = await import('./harness.ts') + await declare(ctx, { id: 'invalid', plugins: [{ name: plugin(name), config: { message: 'refused', service: 'fixtureService' } }] }) + expect((await ctx.agentPresets.resolve('invalid')).broken).toContain(reason) + expect(ctx.get('fixtureService')).toBeUndefined() + expect(livePresetMounts(ctx.fiber)).toEqual([]) +}) + +it('keeps a row waiting for an absent service mounted, reports it, and refuses bindings', async () => { + const ctx = await setup() + const { plugin } = await import('./harness.ts') + await declare(ctx, { id: 'invalid', plugins: [{ name: plugin('needs-missing') }] }) + expect((await ctx.agentPresets.resolve('invalid')).broken).toContain('waiting for serviceThatDoesNotExist') + expect(livePresetMounts(ctx.fiber)).toHaveLength(1) + await expect(ctx.agentPresets.mount(createScope(ctx, {}).ctx, 'invalid')).rejects.toThrow('waiting for serviceThatDoesNotExist') + expect((await ctx.agentPresets.list())[0]!.broken).toContain('waiting for serviceThatDoesNotExist') +}) + +it('waits for a Host provider still activating instead of recording the preset as broken', async () => { + const ctx = await setup() + const { plugin } = await import('./harness.ts') + let release!: () => void + const paused = new Promise((resolve) => { release = resolve }) + let entered!: () => void + const started = new Promise((resolve) => { entered = resolve }) + ctx.loader.builtins.slowProvider = { async apply(host: Context) { + entered() + await paused + host.provide('serviceThatDoesNotExist', { label: 'late host' }) + } } + const hostUpdate = ctx.loader.root.update([{ id: 'slow', name: 'cordis:slowProvider' }]) + await started + await declare(ctx, { id: 'late', plugins: [{ name: plugin('needs-missing') }] }) + expect(livePresetMounts(ctx.fiber)).toHaveLength(1) + const acquiring = ctx.agentPresets.acquireScope('late') + const outcome = await Promise.race([ + acquiring.then(() => 'settled', () => 'settled'), + new Promise((resolve) => { setImmediate(() => { resolve('waiting') }) }), + ]) + expect(outcome).toBe('waiting') + release() + await hostUpdate + const lease = await acquiring + expect(await ctx.agentPresets.resolve('late')).toEqual({ id: 'late' }) + expect((await ctx.agentPresets.compositionInventory())[0]).toMatchObject({ id: 'late', + rows: [expect.objectContaining({ moduleName: plugin('needs-missing'), enabled: true })] }) + await lease[Symbol.asyncDispose]() +}) + +it('allows an isolated service and resolves it through the Agent composition', async () => { + const ctx = await setup() + const { plugin } = await import('./harness.ts') + await declare(ctx, { id: 'standard', plugins: [{ name: 'cordis:group', group: true, + isolate: { fixtureService: true }, config: [{ name: plugin('global-service'), config: { service: 'fixtureService', label: 'scoped' } }] }] }) + const scope = createScope(ctx, {}) + await ctx.agentPresets.mount(scope.ctx) + expect(ctx.agentPresets.serviceFor({ ctx: scope.ctx }, 'fixtureService' as string & keyof Context)).toEqual({ label: 'scoped' }) + expect(ctx.agentPresets.serviceFor({ ctx }, 'fixtureService' as string & keyof Context)).toBeUndefined() + expect(ctx.agentPresets.serviceFor({ ctx: scope.ctx }, 'loader')).toBeUndefined() +}) + +it('keeps policy preferences while hiding and restoring the chooser', async () => { + const ctx = await setup() + const { default: Settings } = await import('@deepseek-ai/dsh-settings-file') + const { mkdtemp, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const pathModule = await import('node:path') + const join = (...parts: string[]) => pathModule.join(...parts) + const path = await mkdtemp(join(tmpdir(), 'preset-policy-')) + const settings = await ctx.plugin(Settings, { path: join(path, 'settings.yml'), watch: false }) + try { + await ctx.settings.update('agent-presets', { default: 'minimal', modeSelectionEnabled: true }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + await ctx.settings.update('agent-presets', { default: 'minimal', modeSelectionEnabled: false }) + expect(ctx.agentPresets.defaultId).toBe('standard') + await ctx.settings.update('agent-presets', { default: 'minimal', modeSelectionEnabled: true }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + await settings.dispose() + expect(ctx.agentPresets.defaultId).toBe('standard') + } finally { await settings.dispose(); await rm(path, { recursive: true, force: true }) } +}) + +it('refuses an unscoped binding and a second child join without leaking references', async () => { + const ctx = await setup() + const definition = await declare(ctx, contribution('standard')) + await expect(ctx.agentPresets.mount(ctx)).rejects.toThrow('scoped context') + await expect(ctx.agentPresets.register({ id: ' ', plugins: [] })).rejects.toThrow('empty') + const parent = createScope(ctx, {}) + await ctx.agentPresets.mount(parent.ctx) + expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow('requires a scope') + const child = createScope(ctx, {}) + ctx.agentPresets.composeFrom(child.ctx, parent.ctx) + expect(() => ctx.agentPresets.composeFrom(child.ctx, parent.ctx)).toThrow('already joined') + await definition.dispose() + await child.dispose() + await parent.dispose() + expect(livePresetMounts(ctx.fiber)).toHaveLength(0) + await expect(ctx.agentPresets.acquireScope()).rejects.toThrow('Unknown') +}) + +it('serializes competing blank-session selections and recovers after a failed selection', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await declare(ctx, contribution('minimal')) + const agent = await agentOn(ctx, 'competing') + const results = await Promise.allSettled([ + ctx.agentPresets.select(agent, 'absent'), ctx.agentPresets.select(agent, 'minimal'), ctx.agentPresets.select(agent, 'standard'), + ]) + expect(results.map(row => row.status)).toEqual(['rejected', 'fulfilled', 'fulfilled']) + expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard') +}) + +it('retains a newly acquired generation when a previous activation is replaced', async () => { + const ctx = await setup() + let release!: () => void + const paused = new Promise((resolve) => { release = resolve }) + let entered!: () => void + const started = new Promise((resolve) => { entered = resolve }) + ctx.loader.builtins.delayed = { async apply() { entered(); await paused } } + const pending = ctx.agentPresets.register({ id: 'standard', plugins: [{ name: 'cordis:delayed' }] }) + await started + const leasePromise = ctx.agentPresets.acquireScope() + release() + const dispose = await pending + const lease = await leasePromise + await dispose() + expect(livePresetMounts(ctx.fiber)).toHaveLength(1) + await lease[Symbol.asyncDispose]() + expect(livePresetMounts(ctx.fiber)).toHaveLength(0) +}) + +it('does not lose the durable selection when a tools observer rejects notification', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await declare(ctx, contribution('minimal')) + const agent = await agentOn(ctx, 'observer') + ctx.on('tools/change', () => { throw new Error('observer refused') }) + expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') + expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') +}) + +it('inventories failed conditional entries without executing their expressions again', async () => { + const ctx = await setup() + await declare(ctx, { id: 'broken', name: 'Broken', plugins: [ + { name: 'missing', disabled: { __jsExpr: 'false' } }, + ] }) + const inventory = await ctx.agentPresets.compositionInventory() + expect(inventory[0]).toMatchObject({ id: 'broken', broken: expect.any(String) as string, + rows: [{ moduleName: 'missing', enabled: 'conditional', condition: 'false' }] }) +}) + +it('does not bind a definition removed while acquisition yields, and disposal is idempotent', async () => { + const ctx = await setup() + const dispose = await ctx.agentPresets.register(contribution('standard')) + const acquiring = ctx.agentPresets.acquireScope() + const rejection = expect(acquiring).rejects.toThrow('Unknown agent preset') + await dispose() + await rejection + await declare(ctx, contribution('standard')) + await dispose() + expect(await ctx.agentPresets.resolve()).toEqual({ id: 'standard' }) +}) + +it('refuses to inherit a revision owned by a different registry', async () => { + const ctx = await setup() + const other = await setup() + await declare(other, contribution('standard')) + const parent = createScope(other, {}) + await other.agentPresets.mount(parent.ctx) + expect(() => ctx.agentPresets.composeFrom(createScope(ctx, {}).ctx, parent.ctx)).toThrow('revision is unavailable') +}) + +it('validates malformed child YAML at the declaring plugin and retains diagnostics', async () => { + const ctx = await setup() + const { default: Preset } = await import('@deepseek-ai/dsh-agent-preset') + ctx.loader.builtins.preset = Preset + await ctx.loader.root.update([{ id: 'invalid', name: 'cordis:preset', config: { id: 'invalid', plugins: [1] } }]) + expect((await ctx.agentPresets.resolve('invalid')).broken).toContain('not a plugin row') + expect((await ctx.agentPresets.compositionInventory())[0]!.rows).toEqual([]) +}) + +it.each([ + ['nope', 'the composition must be a top-level list of plugin rows'], + [[[]], 'row 1 is not a plugin row (expected a map with a "name")'], + [[{ name: '' }], 'row 1 names no plugin (a "name" string is required)'], + [[{ name: 'group', group: true, config: 'x' }], 'group row 1 must hold a list of plugin rows'], + [[{ name: 'group', group: true, config: [{}] }], 'row 1 row 1 names no plugin (a "name" string is required)'], + [[{ name: 'group', group: true, config: [{ name: 'child' }] }], undefined], +])('names the first invalid row of %j', (rows, problem) => { + expect(entryListProblem(rows)).toBe(problem) +}) + +it('reports every process-global service published by a preset', async () => { + const ctx = await setup() + ctx.loader.builtins.leaks = { apply(child: Context) { + child.provide('presetZulu', {}) + child.provide('presetAlpha', {}) + } } + await declare(ctx, { id: 'leaks', plugins: [{ name: 'cordis:leaks' }] }) + expect((await ctx.agentPresets.resolve('leaks')).broken).toContain('presetAlpha, presetZulu') +}) diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-preset-registry/tests/session.spec.ts similarity index 100% rename from packages/preset/agent-presets/tests/session.spec.ts rename to packages/preset/agent-preset-registry/tests/session.spec.ts diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-preset-registry/tsconfig.json similarity index 81% rename from packages/preset/agent-presets/tsconfig.json rename to packages/preset/agent-preset-registry/tsconfig.json index f21a5996f9..74a8b2e7af 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-preset-registry/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../boot/app-boot" @@ -52,10 +54,19 @@ "path": "../../util/atomic-write" }, { - "path": "../../util/home-paths" + "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../boot/hmr" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../interaction/user-approval" } ] } diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-preset/README.i18n.yaml similarity index 70% rename from packages/preset/agent-presets/README.i18n.yaml rename to packages/preset/agent-preset/README.i18n.yaml index c21f9f13b5..7ef142d9cc 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-preset/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: d0eb1c108eaef091c776992c144ba389be2cf6c0 -README.zh.md: e9c1a35b22a1764e444c5757a1e34761bdc24fdf +# pnpm run verify-translation-pairing --write packages/preset/agent-preset/README.md +README.md: b9274969b5c219e9120ba754864e87c03c409bcb +README.zh.md: aba74e630713291d1f17a4fe5668aed8985521ea diff --git a/packages/preset/agent-preset/README.md b/packages/preset/agent-preset/README.md new file mode 100644 index 0000000000..b9274969b5 --- /dev/null +++ b/packages/preset/agent-preset/README.md @@ -0,0 +1,92 @@ +--- +description: "Define an Agent’s child plugins in ordinary Cordis YAML. Declare several presets and let sessions select one. Definitions load eagerly, and edits affect subsequently created Agents." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-agent-preset + +English | [中文](README.zh.md) + +## Summary + +Define an Agent’s child plugins in ordinary Cordis YAML. Declare several presets and let sessions select one. Definitions load eagerly, and edits affect subsequently created Agents. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +```yaml +- id: agent-preset-registry + name: '@deepseek-ai/dsh-agent-preset-registry' + config: + default: standard +- id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + plugins: [] +``` + +| Field | Default | Meaning | +|---|---|---| +| `id` | required | Stable preset identifier | +| `plugins` | required | Child plugin entry list | +| `name` | unset | Display name | +| `description` | unset | Display description | +| `order` | unset | Roster order | + +The declaration row’s `id` addresses Loader edits; `config.id` is the preset identity saved by sessions. Child entry IDs may be omitted and assigned by Loader. + + +## Understand the implementation + +
+Implementation internals — click to expand + +[index.ts](src/index.ts) registers a definition and returns its disposer to Cordis. The plugin’s group marker preserves child `!!js` expressions until their own plugins load. The [registry](../agent-preset-registry/README.md) owns revision retention and release. + +
+ + +## Further Exploration + +- [Scope](../../core/scope/README.md) — Registration isolation. +- [Agent](../../core/agent/README.md) — Session runtime. +- [Cordis](../../../docs/cordis-primer.md) — Plugin configuration and lifecycle. + + +## Model Experience + +Indirectly, through the declared plugins, which own model-visible tools and prompts. + +#### KV Cache effect + +A live Agent’s composition stays stable; new Agents can use an updated definition. + +## Known Limitations and Deferred Work + + + +- Requires the `agentPresets` service. Duplicate preset IDs fail declaration loading. Declarations provide no directory, file-copy or file-delete operations. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. This plugin submits registry-owned definitions and has no independently mutable runtime state. diff --git a/packages/preset/agent-preset/README.zh.md b/packages/preset/agent-preset/README.zh.md new file mode 100644 index 0000000000..aba74e6307 --- /dev/null +++ b/packages/preset/agent-preset/README.zh.md @@ -0,0 +1,92 @@ +--- +description: "在普通 Cordis YAML 中定义 Agent 的子插件列表。可同时声明多个 preset,由会话选择。声明会提前加载,修改只影响之后创建的 Agent。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-agent-preset + +[English](README.md) | 中文 + +## 概述 + +在普通 Cordis YAML 中定义 Agent 的子插件列表。可同时声明多个 preset,由会话选择。声明会提前加载,修改只影响之后创建的 Agent。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +```yaml +- id: agent-preset-registry + name: '@deepseek-ai/dsh-agent-preset-registry' + config: + default: standard +- id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + plugins: [] +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `id` | 必填 | 稳定的 preset 标识符 | +| `plugins` | 必填 | 子插件行列表 | +| `name` | 未设置 | 展示名称 | +| `description` | 未设置 | 展示说明 | +| `order` | 未设置 | 列表排序 | + +声明行的 `id` 是 Loader 编辑地址;`config.id` 是会话保存的 preset 标识符。子插件可省略行 ID,由 Loader 分配。 + + +## 理解实现 + +
+实现细节 — 点击展开 + +[index.ts](src/index.ts) 注册定义并把清理函数交给 Cordis。插件的 group 标记保留子配置中的 `!!js`,直到对应子插件加载时才求值。代际持有与释放由 [注册表](../agent-preset-registry/README.zh.md) 管理。 + +
+ + +## 延伸阅读 + +- [Scope](../../core/scope/README.zh.md) — 注册隔离。 +- [Agent](../../core/agent/README.zh.md) — 会话运行时。 +- [Cordis](../../../docs/cordis-primer.zh.md) — 插件配置与生命周期。 + + +## 模型体验 + +通过声明的插件间接影响模型,这些插件拥有模型可见工具和提示词。 + +#### KV Cache effect + +同一 Agent 的组合保持稳定;新 Agent 可使用更新后的定义。 + +## 已知限制与待办 + + + +- 需要 `agentPresets` 服务。重复的 preset ID 会导致声明加载失败。声明不提供目录、文件复制或文件删除操作。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
+ +**运行时不变量:** 不发布 companion;本插件仅提交由注册表管理的定义,没有可独立偏离的运行时状态。 diff --git a/packages/preset/agent-preset/package.json b/packages/preset/agent-preset/package.json new file mode 100644 index 0000000000..05c622fbf4 --- /dev/null +++ b/packages/preset/agent-preset/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-agent-preset", + "description": "Declare an Agent capability composition in Cordis YAML", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/preset/agent-preset" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "skills", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" + } +} diff --git a/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md similarity index 100% rename from packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md rename to packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md diff --git a/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md new file mode 100644 index 0000000000..8110b3f80a --- /dev/null +++ b/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md @@ -0,0 +1,80 @@ +--- +name: editing-cordis-compositions +description: Use when creating, changing, or validating an agent preset or other Cordis composition for this harness, including deciding host versus preset placement and checking that an authored preset mounts. +--- + +# Editing Cordis compositions + +Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file is the complete contract; do not read DSH package sources to infer formats. + +## Where declarations live + +The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, that bundle sits at `$DSH_HOME/profiles//node_modules/@deepseek-ai/dsh-web-app/`; in a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with `cat` when you need a template; `minimal.patch.yml` is the shortest. + +A declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention. + +## Create a preset + +Write a bundle directory in the workspace with exactly two files, then install it. + +`review-preset/package.json`: + +```json +{ + "name": "@local/dsh-review-preset", + "version": "1.0.0", + "private": true, + "type": "module", + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} +``` + +`review-preset/cordis.patch.yml`: + +```yaml +- insert: + - id: preset-review + name: '@deepseek-ai/dsh-agent-preset' + config: + id: review + name: Review + description: Reviews changes with the shell only. + order: 10 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + prefix: You review software changes. + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' +``` + +Install with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands. + +## Change a shipped preset + +Override the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries: + +```yaml +- id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + order: 1 + plugins: + # the shipped list with your changes +``` + +## Verify + +`plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval. + +## Choose plugin placement + +Host plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents. + +A preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory. + +## Extend the Host + +For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. diff --git a/packages/preset/agent-preset/src/index.ts b/packages/preset/agent-preset/src/index.ts new file mode 100644 index 0000000000..2e718fa454 --- /dev/null +++ b/packages/preset/agent-preset/src/index.ts @@ -0,0 +1,30 @@ +/** A declarative preset row in an ordinary Cordis composition. */ +import { Context, Service } from '@deepseek-ai/cordis' +import { EntryGroup } from '@deepseek-ai/cordis-plugin-loader' +import z from '@deepseek-ai/schemastery' +import type { PresetDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' + +/** Definition submitted to the preset registry. */ +export type Config = PresetDefinition + +/** Registers child plugin configuration without owning Agents using older revisions. */ +export default class AgentPreset { + static inject = ['agentPresets'] + /** Preserve child expressions until their own plugins activate. */ + static readonly [EntryGroup.key] = true + static Config: z = z.object({ + id: z.string().required(), + name: z.string(), + description: z.string(), + order: z.number(), + // Cordis owns individual plugin schemas; the registry validates entry structure. + plugins: z.array(z.any()).required(), + }) as z + + constructor(private readonly ctx: Context, private readonly config: Config) {} + + async* [Service.init]() { + yield await this.ctx.agentPresets.register(this.config) + } +} diff --git a/packages/preset/agent-preset/tsconfig.json b/packages/preset/agent-preset/tsconfig.json new file mode 100644 index 0000000000..7bef41f4b9 --- /dev/null +++ b/packages/preset/agent-preset/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../agent-preset-registry" + } + ] +} diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md deleted file mode 100644 index d0eb1c108e..0000000000 --- a/packages/preset/agent-presets/README.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -description: "Per-session agent composition from preset cordis.yml files, for users and maintainers choosing, configuring, or debugging agent presets." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-agent-presets - -English | [中文](README.zh.md) - -## Summary - -Use `dsh-agent-presets` to give each session the tools, prompt sections, and skills named by one preset's `agent.cordis.yml`. One process can run sessions with different presets while keeping their state separate. The preset list combines shipped definitions with configured and user roots, reports why a preset cannot start, and can create a local preset by copying an existing one. Deployments and users can choose defaults; only an empty session may switch presets. Treat every authored preset as trusted configuration because it grants the capabilities of the plugins it selects. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount this package in a composition that should give each agent session its own tools, prompt sections, and skills from a preset file. Every session names a preset — explicitly or through the configured default — and is composed from it; without the package, sessions fall back to whatever the host composition mounts. - -The shipped Web `standard`, `ptc`, and `cordis` presets include [explicit file delivery](../../client/ui-deliverables/README.md#explicit-deliveries). The `minimal` preset keeps its fixed two-tool training configuration. - -### What a preset gives a session - -A session composed from a preset runs the plugins that preset's `agent.cordis.yml` names: its tools, prompt sections, and skills. Sessions joined to the same preset share one installed composition, and each session's state stays separate. A child agent (subagent) joins its parent's composition, so it sees the same tools and prompt sections as the agent that spawned it. - -The presets you can choose from come from three sources: the presets shipped inside this package under `presets/`, configured roots, and your own presets under `/.agent-presets`. The picker shows each preset's display name and description; a preset whose composition cannot load is listed with the reason rather than hidden, so you can see what to fix or delete. - -### Minimal configuration - -The plugin needs a `default` preset id and scans `roots` for presets: - -```yaml -- name: '@deepseek-ai/dsh-agent-presets' - config: - default: standard - roots: - - path: ~/company-presets - trust: system -``` - -| Field | Default | Meaning | -|---|---|---| -| `default` | required | Deployment fallback preset id, used while mode selection is disabled or no user default overrides it | -| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | -| `includeShippedRoot` | `true` | Prepend the package's bundled presets as a `system` root before every configured root | -| `includeUserRoot` | `true` | Append `/.agent-presets` as a `user` root, after every configured root | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-presets) is the exhaustive source for every accepted field and its JSDoc. - -The shipped root is prepended before every configured root, so the built-in set remains available and wins duplicate ids even when a patch replaces the roster configuration. `includeShippedRoot: false` drops that built-in set for deployments that supply all presets themselves. `includeUserRoot: false` drops the derived writable root; tests that pin an exact roster disable both derived roots. - -### Showing the picker and choosing its default - -The required `default` config sets the deployment default. When a settings provider is composed, this plugin registers the `agent-presets` namespace with `{ default: config.default, modeSelectionEnabled: true }` as its base, so the existing new-session picker remains visible unless a user turns it off. The Host reads both fields on every default resolution: while `modeSelectionEnabled` is false, an omitted preset resolves to `config.default` even if the user document retains another `default`; while it is true, a user default may override the deployment value: - -```yaml -agent-presets: - modeSelectionEnabled: true - default: minimal -``` - -A client shows or hides selection by writing only `modeSelectionEnabled`; the [Web GUI settings switch](../../client/ui-agent-preset/README.md) does exactly that. The deployment default governs while selection is hidden; re-enabling it restores the saved user `default`, or keeps the deployment default when none has been saved. While mode selection stays enabled, choosing a default writes a user override for sessions created later. Because the Host owns the policy, it applies to every subsequently created session whose caller omits a preset, including Web, CLI, SDK, and headless callers; an explicitly named preset and every existing session remain unchanged. - -### Authoring presets - -Authoring is copy-only: creating a preset copies an existing preset's whole directory — composition, display metadata, skill directories, assets — into the first `user` root. The copy keeps the source's description but gets its own id and an optional display name, so no caller supplies composition text and a copy grants nothing the roster did not already carry. After creation, everything happens in the preset's own files. - -A copy is refused when the id is not `[a-z0-9][a-z0-9-]*` (the id becomes a directory name), when the id is already taken (a copy never overwrites), or when the source is unknown. Deleting removes only locally authored presets; presets that ship with the deployment are not removable. A session already running on a deleted preset keeps running on it. - -### Switching a session's preset - -A session can switch to a different preset only while it has produced nothing — no messages or tool calls. After that, the composition is fixed for the session's life, because swapping tools mid-conversation would leave logged tool calls the new composition cannot make. A committed switch emits `tools/change` because the resolved tool set changed without a registry edit. The switch is also recorded in the session log, so a resumed or forked session rebuilds under the composition it ran. - -### Failures and recovery - -A preset whose composition is missing, unparsable, not a list of named plugin rows, or naming a module that cannot be resolved is listed as broken with a reason naming the rows at fault; a package-lookup failure marks only the preset being checked as broken, while the rest of the roster remains available. Composing a broken preset is refused up front, so a session never starts half-composed. What survives to session creation is a row whose module loads and then refuses — a plugin that throws, or one waiting for a service the composition never supplies — which fails the creation and rolls it back, naming every failed row including those inside a group. Fix the preset's file or delete it, then retry. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -This section explains the design behind the roster and the standing mount; observable behavior is fully covered in [Use this package](#use-this-package). - -### Design philosophy - -- **One standing composition per preset.** A preset is mounted once per process under a standing scope; agents join by parenting their scope key to the mount, so the mount's registrations and listeners cover every joined agent and no sibling preset's. -- **Generations keyed on the composition file.** The mount records the composition file's stamp (mtime and size); a session that finds the stamp stale starts the next generation, while sessions already joined keep the generation they run on — a running session outlives its file changing or disappearing. -- **The preset file is an input, never a persistence target.** The mounted subtree overrides `write()` as a no-op, so a loader-initiated write-back never rewrites a shared preset file. -- **Discovery owns health.** A directory whose composition is missing or unloadable is a broken roster row with a reason, not a skip — a skipped directory would still occupy its id while no surface shows anything to delete. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Service entry: `Config` schema, settings namespace, roster API, standing-mount coordination | -| [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, health checks, id validation, ordering | -| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates, mount reads with fiber states | -| [`src/preset.ts`](src/preset.ts) | Vocabulary: preset id rule, `AgentPreset` and `PresetRoot`, error types | -| [`src/mount.ts`](src/mount.ts) | Subtree mounting, host base-URL handling, mount audit, `write()` suppression | -| [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, permission tightening | -| [`src/metadata.ts`](src/metadata.ts) | `preset.yml` display metadata | -| [`src/session.ts`](src/session.ts) | `agent-preset/selected` event and the `agentPreset` Session projection | -| [`src/types.ts`](src/types.ts) | Client-safe wire payloads and cordis event declaration | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: post-mount service-leak recheck, unjoined-agent failure | - -### The standing mount - -`ensureStanding` keeps one pending promise per preset id, single-flight, so two agents racing the first use of a preset share one composition. A settled failure is removed so a later session retries a preset whose file has been fixed. The mount runs in the roster service's own untraced context — a subtree minted from a traced context would resolve services through the caller's shadow fiber — so it survives every agent and unwinds only with whole-tree teardown. `serviceForAgent` reads an agent's instance of a service its preset mounted behind an `isolate` realm, which is otherwise invisible outside the group. - -### The composition inventory - -`compositionInventory()` answers plugin-listing surfaces with each preset's flattened rows beside its roster identity (id, trust, display name, default marking): a preset with a live standing mount — matched within this runtime's own root, so a second Cordis runtime in the same process never answers for it — answers from its newest generation's Loader entries, even when its file has since broken, because the mount is what sessions run and the broken verdict applies only to a preset nothing composed; one never composed since boot answers from its composition file with `!!js` disabled gates evaluated against the Loader context, so both answers reflect the same host. Reading never mounts a preset — a settings page listing every composition activates none of them. A gate the evaluator refuses stays `'conditional'`, and a file that stopped reading as a composition between discovery's health verdict and the row read is reported broken with the raced reason rather than dropped. The `./display` subpath exports the `presetDisplayText` fold mapping shipped preset ids to their dictionary copy keys; it has no imports, browser bundles inline it, and it is the one home for which shipped id carries which copy. - -### The mount audit - -A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects an import or activation failure, an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. - -### Authoring mechanics - -A copy dereferences symlinks so it is self-contained, re-tightens the tree to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), and creates the root on first copy. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit, its name and roster `order` dropped, so the roster keeps distinguishing the copy from its source. Removal refuses presets that ship with the deployment and clears a user default that named the preset just deleted. - -### The session record - -The creation header names the preset a session started with; the `agentPreset` Session projection names the preset it runs. A switch appends an `agent-preset/selected` event after the swap commits because the preset decides the tool schemas and prompt sections the model sees. The service re-emits that committed fact as the unscoped cordis event `agent-preset/selected(sessionId, agentPreset)`. Reconstruction consumes the projection, which starts from the creation header and applies the newest selection; it never folds the log independently. - -
- ------ - - -## Further Exploration - -Read these pages when the package-level contract is not enough; they move from the composition model to the scope and prompt mechanics the mount relies on, and to the decision evidence. - -- [persona package](../persona/README.md) — the composable row a preset mounts to give a session its own persona. -- [Scope subsystem](../../../docs/subsystems/scope.md) — scope keys and the parent chain agents join through. -- [System prompt subsystem](../../../docs/subsystems/system-prompt.md) — how preset prompt sections register and assemble. -- [Session package map](../../session/README.md) — the durable session record a preset switch appends to. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-presets) — every accepted config field and its source declaration. -- [Per-session agent presets note](../../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md) — design rationale and alternatives. - ------ - - -## Model Experience - -Indirectly, through the plugins a preset's standing composition installs, which own every tool schema, prompt section, and skill the preset makes visible to the agents joined to it. - -#### KV Cache effect - -Prefix-stable for the life of an agent: a composition is installed once, before the agent is published and therefore before its first request, and is never re-read while the agent runs. Choosing a different preset for a new session establishes a different prefix for that session alone and cannot invalidate reuse for any session already running. - -## Known Limitations and Deferred Work - - - - -These limits define when the roster is a poor fit or needs special operational care. They are current package constraints, not a general composition comparison or a task backlog. - -- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the first `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and answers "it does not live under the writable preset root" for every delete. A deployment that wants only its own presets sets `includeUserRoot: false`. -- **A session cannot change preset once it has produced anything** — switching re-links a blank session's parent scope to another standing mount, and only a blank one: swapping tools mid-conversation would strand tools the model has called. -- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. -- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-filesystem` watches its roots by default, so each edit-then-create cycle adds a live watcher set. -- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start. -- **Health asks what is installed, not what would import** — discovery proves the composition parses in the loader dialect, holds named rows, and that each row it can prove will start names a package present above the harness base or a file that exists; it never imports one, so a package whose own entry file is missing, a plugin that throws on apply, and one waiting forever for a service all still fail at the first session. `disabled` is the one entry field the Loader interpolates, so a row carrying an expression there is left unchecked rather than judged from the file. -- **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change"; the shipped set itself accepts the same cost — `cordis` and `code` each duplicate `standard`'s full assembly and then edit it — so the whole assembly stays readable in one file. -- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`. - - -### Dev Note - -
-Working context for maintainers — click to expand - -This Dev Note is working context for maintainers: open design questions and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked Agent Notes. - -#### Future: reclaiming superseded generations - -Reclaiming a superseded standing mount needs a joined-agent count on `StandingMount`, incremented in `mount`/`composeFrom`/`recompose` and decremented when the agent's scope key dies — the `TODO` at `ensureStanding`. The subtree is not inert: `dsh-skill-filesystem` watches its roots, so an unreclaimed generation keeps a live watcher set alive until the process ends. - -
diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md deleted file mode 100644 index e9c1a35b22..0000000000 --- a/packages/preset/agent-presets/README.zh.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -description: "按 preset cordis.yml 文件进行按会话的 agent(智能体)组装,供选择、配置或排查 agent preset 的用户与维护者阅读。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-agent-presets - -[English](README.md) | 中文 - -## 概述 - -使用 `dsh-agent-presets` 为每个会话提供某个 preset 的 `agent.cordis.yml` 所指定的工具、提示词段落与 skill(技能)。一个进程可以运行使用不同 preset 的会话,同时保持它们的状态相互隔离。preset 名单合并随附定义、已配置根目录与用户根目录,会报告 preset 无法启动的原因,也能通过复制现有 preset 创建本地 preset。部署与用户都可选择默认值;只有空会话可以切换 preset。请将每个自行编写的 preset 视为受信任配置,因为它会授予其所选插件的能力。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在需要让每个 agent 会话从 preset 文件获得自己的工具、提示词段落与 skill 的组装中挂载本包。每个会话都会命名一个 preset——显式指定或通过配置的默认值——并据此组装;没有本包时,会话只能回退到宿主组装挂载的内容。 - -随附 Web 的 `standard`、`ptc` 与 `cordis` preset 包含[显式文件交付](../../client/ui-deliverables/README.zh.md#explicit-deliveries)。`minimal` preset 保留固定的双工具训练配置。 - -### preset 给会话带来什么 - -从 preset 组装的会话会运行该 preset `agent.cordis.yml` 所列插件:它的工具、提示词段落与 skill。加入同一 preset 的会话共享一份已安装的组装,且各会话的状态彼此隔离。subagent 会加入其父方的组装,因此它看到的工具与提示词段落和创建它的 agent 相同。 - -可选的 preset 来自三类来源:本包 `presets/` 下随包交付的 preset、已配置的根目录,以及你自己放在 `/.agent-presets` 下的 preset。选择器会展示每个 preset 的显示名与描述;组装无法加载的 preset 会连同原因一起列出而不是被隐藏,因此你能看到该修什么或删什么。 - -### 最小配置 - -插件需要一个 `default` preset id,并在 `roots` 中扫描 preset: - -```yaml -- name: '@deepseek-ai/dsh-agent-presets' - config: - default: standard - roots: - - path: ~/company-presets - trust: system -``` - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `default` | 必填 | 部署 fallback preset id;模式选择关闭或没有用户默认值覆盖时使用 | -| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | -| `includeShippedRoot` | `true` | 在全部已配置根目录之前,前置本包随附的 preset 作为 `system` 根目录 | -| `includeUserRoot` | `true` | 在全部已配置根目录之后追加 `/.agent-presets` 作为 `user` 根目录 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-presets)是每个受支持字段及其 JSDoc 的穷尽式真源。 - -随附根目录前置在全部已配置根目录之前,因此即使补丁替换 roster 配置,内置集合仍然可用并赢得重复 id。`includeShippedRoot: false` 会为完全自行提供 preset 的部署移除内置集合。`includeUserRoot: false` 会移除推导出的可写根目录;钉住确切 roster 的测试会同时关闭两个推导根目录。 - -### 显示选择器并选择默认 preset - -必填的 `default` 配置设定部署默认值。当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `{ default: config.default, modeSelectionEnabled: true }` 作为 base,因此既有的新建会话选择器会保持显示,除非用户主动关闭。Host 每次解析默认值都会读取这两个字段:`modeSelectionEnabled` 为 `false` 时,未显式指定 preset 的会话解析为 `config.default`,即使用户文档还保留其他 `default` 也会忽略它;该字段为 `true` 时,用户默认值才可覆盖部署值: - -```yaml -agent-presets: - modeSelectionEnabled: true - default: minimal -``` - -客户端只需写入 `modeSelectionEnabled` 即可显示或隐藏选择,[Web GUI 设置开关](../../client/ui-agent-preset/README.zh.md)正是这样做的。选择器隐藏期间由部署默认值生效;再次开启时恢复已保存的用户 `default`,尚未保存时则继续使用部署默认值。模式选择保持开启时,选择默认模式会写入用户覆盖值,仅供此后创建的会话使用。由于该策略归 Host 所有,它适用于 Web、CLI、SDK 与 headless 调用方此后创建的全部未显式指定 preset 的会话;显式指定的 preset 与任何既有会话均不受影响。 - -### 创作 preset - -创作即复制:创建 preset 会复制某个既有 preset 的整个目录——组装、展示元数据、skill 目录与资产——放进第一个 `user` 根目录。副本保留来源的描述,但拥有自己的 id 与可选显示名,因此调用方从不提供组装文本,一次复制也不会授予名单尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。 - -以下情况会拒绝复制:id 不符合 `[a-z0-9][a-z0-9-]*`(id 会成为目录名)、id 已被占用(复制从不覆写)、或来源未知。删除只移除本地创作的 preset;随部署提供的 preset 不可删除。已在被删除 preset 上运行的会话会继续运行。 - -### 切换会话的 preset - -会话只有在尚未产出任何内容——没有消息或工具调用——时才能切换到不同的 preset。此后组装在会话的生命周期内固定,因为在对话中途调换工具会留下新组装无法执行的已记录工具调用。已提交的切换会发出 `tools/change`,因为解析后的工具集在没有注册表编辑的情况下发生了变化。切换也会记入会话日志,因此恢复或 fork 的会话会按它运行的组装重建。 - -### 失败与恢复 - -组装缺失、无法解析、不是具名插件行列表,或者引用了无法解析的模块的 preset 会被列为 broken,原因会指名出问题的行;包查询失败只会把正在检查的 preset 标为 broken,名单中的其他 preset 仍然可用。组装 broken preset 会被提前拒绝,因此会话绝不会以半组装状态启动。能活到会话创建的,是模块能加载但随后拒绝的行——抛错的插件,或等待组装从未提供的服务的插件——它会让创建失败并回滚,且会指名每一个失败的行,包括组内的行。修复 preset 的文件或删除它,然后重试。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -本节解释名单与常驻挂载背后的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。 - -### 设计理念 - -- **每个 preset 一份常驻组装。** preset 在进程内只挂载一次,挂到常驻 scope 之下;agent 通过把自己的 scope key 认父到该挂载来加入,因此挂载的注册与监听器覆盖每个已加入的 agent,而不覆盖兄弟 preset 的。 -- **代际以组装文件为键。** 挂载记录组装文件的 stamp(mtime 与大小);发现 stamp 过期的会话会开启下一个代际,而已加入的会话保持各自运行的那个代际——运行中的会话在文件被修改或删除后继续存活。 -- **preset 文件是输入,绝不是持久化目标。** 被挂载的子树把 `write()` 覆写为空操作,因此 loader 发起的写回绝不会重写共享的 preset 文件。 -- **发现过程拥有健康。** 组装缺失或不可加载的目录是携带原因的 broken 名单行,而不是被跳过——被跳过的目录仍占着它的 id,而任何界面都没有可删的东西。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 服务入口:`Config` schema、settings 命名空间、名单 API、常驻挂载协调 | -| [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、健康检查、id 校验、排序 | -| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门)与挂载读取(携带 fiber 状态) | -| [`src/preset.ts`](src/preset.ts) | 词汇体系:preset id 规则、`AgentPreset` 与 `PresetRoot`、错误类型 | -| [`src/mount.ts`](src/mount.ts) | 子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 | -| [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 | -| [`src/metadata.ts`](src/metadata.ts) | `preset.yml` 展示元数据 | -| [`src/session.ts`](src/session.ts) | `agent-preset/selected` 事件与 `agentPreset` Session 投影 | -| [`src/types.ts`](src/types.ts) | client-safe 的协议载荷与 cordis 事件声明 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:挂载后的服务泄漏复查、未加入 agent 的失败 | - -### 常驻挂载 - -`ensureStanding` 为每个 preset id 保留一个进行中的 promise(single-flight),因此两个竞争首次使用同一 preset 的 agent 共享一份组装。已结算的失败会被移除,以便后续会话重试文件已被修复的 preset。挂载运行在 roster 服务自己的未追踪上下文中——从被追踪上下文派生的子树会经调用方的 shadow fiber 解析服务——因此它比任何 agent 都活得久,只随整棵树卸载。`serviceForAgent` 读取某 agent 对其 preset 挂在 `isolate` realm 之后(组外不可见)的某个服务实例。 - -### 组合清单 - -`compositionInventory()` 向插件清单表面提供每个预设的压平行及其名单身份(id、trust、显示名、默认标记):已有存活 standing mount 的预设由其最新世代的 Loader 条目作答——匹配限定在本运行时自己的 root 内,同进程里的第二个 Cordis 运行时不会替它作答;即使文件事后损坏也照常作答,因为挂载才是会话实际运行的组合,broken 裁决只适用于无人组合的预设——开机以来从未被组合的预设由其组合文件作答,`!!js` disabled 门用 Loader 上下文求值,使两种答案反映同一台宿主。读取从不挂载预设——列出所有组合的设置页不会激活其中任何一个。求值器拒绝的门保持 `'conditional'`;在发现的健康裁决与行读取之间变得不可读的文件,会携带竞态原因报告为 broken,而不是被静默丢弃。`./display` 子路径导出 `presetDisplayText` 映射,把随附 preset id 映射到各自的字典文案键;它没有任何 import,浏览器包直接内联,也是「哪个内置 id 对应哪份文案」的唯一归属地。 - -### 挂载审计 - -直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝导入或激活失败、无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 - -### 创作机制 - -复制会解引用符号链接以保证自包含,把目录树收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),并在首次复制时创建根目录。复制出的 `preset.yml` 会被重写:保留来源的描述供作者编辑,丢弃其名称与 roster `order`,从而让名单始终能区分副本与来源。删除拒绝随部署提供的 preset,并清除指向刚删除 preset 的用户默认值。 - -### 会话记录 - -创建 header 记录会话启动时使用的 preset;`agentPreset` Session 投影记录会话运行时使用的 preset。切换在替换提交后追加 `agent-preset/selected` 事件,因为 preset 决定模型看到的工具 schema 与提示词段落。服务把这项已提交事实重新发为不带 scope 的 cordis 事件 `agent-preset/selected(sessionId, agentPreset)`。重建消费该投影;投影从创建 header 开始并应用最新选择,绝不单独折叠日志。 - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下页面;它们从组装模型逐步进入挂载所依赖的 scope 与提示词机制,以及决策证据。 - -- [persona 包](../persona/README.zh.md)——preset 挂载的可组装行,让会话拥有自己的人设。 -- [Scope 子系统](../../../docs/subsystems/scope.zh.md)——scope key 与 agent 加入所经由的父链。 -- [系统提示词子系统](../../../docs/subsystems/system-prompt.zh.md)——preset 提示词段落如何注册与组装。 -- [会话包映射](../../session/README.zh.md)——preset 切换所追加的持久会话记录。 -- [生成的配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-presets)——每个受支持配置字段及其源声明。 -- [按会话组装 agent preset 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md)——设计理由与备选方案。 - ------ - - -## 模型体验 - -间接地,经由 preset 常驻组装安装的插件:这些插件拥有该 preset 向加入它的 agent 呈现的每个工具 schema、提示词段落与 skill。 - -#### KV Cache 影响 - -在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。 - -## 已知限制与延期工作 - - - - -这些限制说明名单何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用组装对比或任务积压。 - -- **位于可写根目录之外的 preset 可被发现却无法删除**——`remove()` 拒绝任何不在第一个 `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。只想要自有 preset 的部署应设置 `includeUserRoot: false`。 -- **会话一旦产出任何内容便无法更换 preset**——切换会把空白会话的父作用域重链到另一个常驻挂载,且仅限空白会话:在对话中途调换工具会抽走模型已调用的工具。 -- **代际只以组装文件为键**——stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 -- **被替代的代际永不回收**——已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-filesystem` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。 -- **副本从不被实际挂载以校验**——它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 -- **健康问的是「装没装」,不是「能不能 import」**——发现过程证明组装能以加载器方言解析、由具名行组成,且对于每个能证明会启动的行,其引用的包存在于 harness 基址以上,或引用的文件确实存在;它从不 import 任何一个,因此入口文件缺失的包、在 apply 时抛错的插件、以及永远等待某个服务的插件,都仍在第一个会话处失败。`disabled` 是加载器唯一会插值的条目字段,因此该字段带表达式的行不予检查,而不是仅凭文件作出判断。 -- **副本是会漂移的快照**——升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义;随附集合自己也接受同样的代价——`cordis` 与 `code` 都复制了 `standard` 的完整组装并在此基础上编辑——换来整份组装在一个文件里可读。 -- **根目录扫描不做监听**——每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:开放设计问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文、包代码和相关 Agent Note 为准。 - -#### 未来:回收被替代的代际 - -回收被替代的常驻挂载,需要给 `StandingMount` 加上已加入 agent 的计数,在 `mount`/`composeFrom`/`recompose` 中递增、在 agent 的 scope key 消亡时递减——即 `ensureStanding` 处的 `TODO`。子树并非惰性:`dsh-skill-filesystem` 监听自己的根目录,因此未回收的代际会让一套活的 watcher 一直存活到进程结束。 - -
diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml deleted file mode 100644 index c0a5ab802a..0000000000 --- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml +++ /dev/null @@ -1,270 +0,0 @@ -# TRUST: Plugin Manager installs persistent, profile-wide code that runs in the Host -# process outside the workspace sandbox. Every call needs Full access or single-call -# approval; granting a call does not change the session permission mode. -# The `cordis` agent preset adds runtime inspection and persistent plugin management. -# -# It exists so a person can ask an agent to author another agent. Everything in -# `standard` is here unchanged; Creator adds persistent plugin management, -# runtime inspection, and composition-authoring skills. The persona is the -# `standard` one: tool descriptions and the skill catalog carry the operating -# detail, and the skills own the off-limits rule (an upgrade overwrites the -# shipped preset install, and corrupting the `cordis` preset disables this mode). -# -# Profile management persists configuration shared by every session in that profile. - -# Same persona as `standard`, shadowing the deployment default for this agent. -# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. -- id: persona - name: '@deepseek-ai/dsh-persona' - config: - suffix: Your working directory is {{cwd}}. - prefix: >- - You are a coding agent powered by the {{model}} model. - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -# ── shell ─────────────────────────────────────────────────────────────────── - -# `shell-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. Both shell tools consume the host -# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are -# host-plane too. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - disabled: !!js process.platform === 'win32' - -- id: tool-pwsh - name: '@deepseek-ai/dsh-tool-pwsh' - disabled: !!js process.platform !== 'win32' - -# ── filesystem ────────────────────────────────────────────────────────────── - -# Both register into the host `tools` registry and provide nothing, so -# they need no realm. The `fs` service and its policy stay in the host. -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - sampleOverCapGlobResults: false - -# ── background jobs ──────────────────────────────────────────────────────── - -# Only the model-facing controls. The task REGISTRY stays on the host plane: -# its producers sit outside any realm this file could put it in — `tool-bash` -# above resolves it with `ctx.get`, and an entry-local realm here is invisible -# to every sibling row, so `run_in_background` would answer "background jobs -# unavailable" while these controls sat in the catalog. The registry is keyed by -# owning agent anyway, so one host instance serves every session. What a preset -# chooses is whether its agent can collect and stop background work at all. -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - -# ── goals ─────────────────────────────────────────────────────────────────── - -# The goal service and session driver stay on the host plane, where the Gateway -# can resolve them. The human command and model-facing tool register into this -# preset's scoped layers. -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - -# ── plan mode ─────────────────────────────────────────────────────────────── - -# Plan state is per-agent by nature, so an entry-local realm is not a -# workaround here — it is the correct lifetime. -- id: planning - name: cordis:group - group: true - isolate: - planMode: true - config: - - id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# ── compaction ────────────────────────────────────────────────────────────── - -# `compaction-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must -# share this realm rather than sit outside it. -# -# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST -# plane, and the rows here resolve that one instance. It takes no configuration, -# keys every fold by Session, and owns the context-meter projection units the -# browser reads for every session — behind a realm those units would come and go -# with whichever presets happen to be mounted. What a preset chooses is whether -# its agent compacts at all, which is `compaction-basic` below. -- id: compaction - name: cordis:group - group: true - isolate: - compaction: true - toolResultPruner: true - config: - - id: compaction-basic - name: '@deepseek-ai/dsh-compaction-basic' - - - id: command-compact - name: '@deepseek-ai/dsh-command-compact' - - - id: tool-result-pruner - name: '@deepseek-ai/dsh-compaction-tool-result-pruner' - config: - thresholdChars: 8192 - headChars: 4096 - tailChars: 1024 - -# ── delegation and workflows ──────────────────────────────────────────────── - -# The `subagents` registry and its spawn/fork backends live in the HOST -# composition: the registry is a process singleton whose cross-session queries -# the api-proxy serves to the browser, and a provider name may only be -# registered once. This preset contributes the delegation TOOLS, which resolve -# that host registry. -# -# `workflows` is different — nothing outside an agent reads it — so every row -# that reaches it shares one entry-local realm here, and a consumer left -# outside would resolve a host registry this preset does not populate. -- id: delegation - name: cordis:group - group: true - isolate: - workflowEngine: true - config: - - id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - - - id: tool-subagent-list-agents - name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' - - - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - modelSelectionSettings: true - backgroundMode: continuable - - # Fork omits model selection so provider/model stay equal to the parent and - # the inherited history remains eligible for KV Cache reuse. This preset - # keeps fork continuable; parent and child inherit the same messaging tool, - # while the parent id and return guidance follow the inherited history. - - id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: continuable - - # Production dsh does not install these optional providers. Install the - # matching Bundle in this Profile and restart the Host, then copy this - # preset and remove `disabled` from the matching tool row. Host availability - # alone grants no tool. - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - backgroundMode: one-shot - maxDepth: provider-managed - - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - backgroundMode: one-shot - maxDepth: provider-managed - - - id: workflow-ptc - name: '@deepseek-ai/dsh-workflow-ptc' - config: - provider: spawn - - - id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - - # Off by default with the base row it mirrors: the tool description - # restricts `ralph` to runs the human explicitly asked for, and completion - # is a worker self-report, not an independent evaluation. Preset files take - # no patches, so a session that wants it duplicates this preset under a new - # id and drops `disabled`; the shipped root shadows a copy that reuses this - # id. - - id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - disabled: true - config: - subagentProvider: spawn - maxRounds: 64 - -# ── remaining model-facing rows ───────────────────────────────────────────── - -- id: tool-ask-user - name: '@deepseek-ai/dsh-tool-ask-user' - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true - -# The `web` service and its search provider stay in the host composition; only -# the model-facing tool is per-session. -- id: tool-web - name: '@deepseek-ai/dsh-tool-web' - config: - fetch: true - searchTimeoutMs: 60000 - -# ── self-modification ─────────────────────────────────────────────────────── - -# Discover the Host and Client APIs used by installed plugins. -- id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' - -# The composition-authoring skill travels with this preset rather than living -# in the user's skill root: it documents THIS deployment's two planes, and a -# preset is the unit that gets copied and edited. `baseUrl` is the preset's -# own directory, so the root resolves wherever the preset is installed. -# Both rows register into THIS preset's layer of the host skill registry, so -# they need no realm; the agent's merged catalog also carries whatever the -# deployment registered globally (repository plugins). -- id: skill-filesystem - name: '@deepseek-ai/dsh-skill-filesystem' - config: - customSkillDirs: - - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -- id: present - name: '@deepseek-ai/dsh-tool-present' - -# Enabled exactly when the host `plugin-manager` service is (`dsh-base` gates -# it on a launched profile), so a profile-less embedding still mounts the preset. -- id: tool-plugin-manager - name: '@deepseek-ai/dsh-plugin-manager/tools' - disabled: !!js "!ctx.get('profileContext')" diff --git a/packages/preset/agent-presets/presets/cordis/preset.yml b/packages/preset/agent-presets/presets/cordis/preset.yml deleted file mode 100644 index ed11b73aa1..0000000000 --- a/packages/preset/agent-presets/presets/cordis/preset.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: 创造模式 -description: 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 -order: 4 diff --git a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md deleted file mode 100644 index dfdc4de73a..0000000000 --- a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: editing-cordis-compositions -description: Use when creating, changing, or validating an agent preset or other Cordis composition for this harness, including deciding host versus preset placement and checking that an authored preset mounts. ---- - -# Editing Cordis compositions - -Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. - -## Off-limits - -**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. - -To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. - -## Decide the plane first - -Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared. - -**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. - -**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. - -**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. - -A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. - -Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile. - -## Authoring a preset - -Use shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods. - -Copy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source. - -Edit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance. - -## Service isolation - -A preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm. - -```yaml -- id: delegation - name: cordis:group - group: true - isolate: - workflowEngine: true - config: - - id: workflow-ptc - name: '@deepseek-ai/dsh-workflow-ptc' - config: - provider: spawn - - id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' -``` - -## Verify a preset - -Check the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds. - -## Native product subagents - -Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: - -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex -dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex -dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code -``` - -Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. - -Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: - -```yaml -- id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - backgroundMode: one-shot - maxDepth: provider-managed - -- id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - backgroundMode: one-shot - maxDepth: provider-managed -``` - -For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. - -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. - -## What not to move into a preset - -`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. diff --git a/packages/preset/agent-presets/presets/minimal/agent.cordis.yml b/packages/preset/agent-presets/presets/minimal/agent.cordis.yml deleted file mode 100644 index 549a73cba2..0000000000 --- a/packages/preset/agent-presets/presets/minimal/agent.cordis.yml +++ /dev/null @@ -1,69 +0,0 @@ -# The `minimal` agent preset: a fixed-prompt, single-tool coding-agent composition. -# -# The persona is the complete system prompt, so global identity, Web orientation, -# tool guidance, and later assembly listeners cannot add prompt text. Runtime -# context snapshots are suppressed for this preset, and the model receives only -# the persistent shell (`bash` on POSIX, `pwsh` on win32). Context compaction is -# absent. - -- id: persona - name: '@deepseek-ai/dsh-persona' - config: - prefix: You are a helpful software engineer assistant. - complete: true - includeRuntimeContext: false - -# The PTY registry is an agent-owned service, so it lives in an entry-local -# realm. The backend still consumes the host sandbox policy and subprocess -# implementation, while the tool registers into this agent's scoped catalog. -# Exactly one shell stack mounts per host: the bash stack gates off win32 and -# its pwsh twin gates off POSIX, mirroring the one-shot shell rows. -- id: persistent-shell - name: cordis:group - group: true - isolate: - terminals: true - config: - - id: pty - name: '@deepseek-ai/dsh-terminal' - - - id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - disabled: !!js process.platform === 'win32' - config: - timeoutMs: 300000 - - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - disabled: !!js process.platform === 'win32' - config: - timeoutMs: 300000 - description: |- - Run commands in a bash shell - * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. - * State is persistent across command calls and discussions with the user. - * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. - * Please avoid commands that may produce a very large amount of output. - * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. - - - id: terminal-pwsh - name: '@deepseek-ai/dsh-terminal-bash' - disabled: !!js process.platform !== 'win32' - config: - shellDialect: pwsh - timeoutMs: 300000 - - - id: persistent-pwsh - name: '@deepseek-ai/dsh-tool-pwsh-persistent' - disabled: !!js process.platform !== 'win32' - config: - timeoutMs: 300000 - description: |- - Run commands in a PowerShell shell - * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * You don't have access to the internet via this tool. - * State is persistent across command calls and discussions with the user. - * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. - * Please avoid commands that may produce a very large amount of output. - * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. diff --git a/packages/preset/agent-presets/presets/minimal/preset.yml b/packages/preset/agent-presets/presets/minimal/preset.yml deleted file mode 100644 index 60d2f10c15..0000000000 --- a/packages/preset/agent-presets/presets/minimal/preset.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: 极简模式 -description: 仅提供持久 shell 的单工具编码 Agent。 -order: 3 diff --git a/packages/preset/agent-presets/presets/ptc/agent.cordis.yml b/packages/preset/agent-presets/presets/ptc/agent.cordis.yml deleted file mode 100644 index 8f0e0ae716..0000000000 --- a/packages/preset/agent-presets/presets/ptc/agent.cordis.yml +++ /dev/null @@ -1,287 +0,0 @@ -# The `ptc` agent preset: the standard coding agent, presented as PTC mode. -# -# Most of `standard` is here unchanged. The deliberate exception is the -# general-purpose `workflow` tool: PTC mode uses `run_code` as its model-authored -# composition surface. The `tool-presentation` row turns the remaining registry -# into a generated SDK, so a sequence that would be five round trips becomes one. -# -# The registry itself stays on the host plane — the agent loop's scheduler and -# the API proxy's presenters are its consumers — so what this preset owns is -# the PRESENTATION of that registry for this agent alone. Native sessions run -# beside this one in the same process, each seeing its own catalog. -# -# This file is an AGENT-PLANE composition. It is mounted under one agent's -# scope context, so every tool and prompt section it registers belongs to that -# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) -# keeps everything a preset must not own: the registries themselves, the -# sandbox and approval stack, persistence, and the model route. -# -# A service row here MUST sit inside a group carrying an `isolate` realm. -# Without one it publishes into the root realm, where it is process-global -# rather than per-session and the second session mounting this preset collides -# with the first; `dsh-agent-presets` rejects that at mount. `true` means an -# entry-local realm — one private instance per mounted session, which is the -# default this deployment wants. A shared label would instead pool one instance -# across every session naming it. - -# ── identity ──────────────────────────────────────────────────────────────── - -# The preset's own persona, shadowing the deployment default for this agent. -# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. -- id: persona - name: '@deepseek-ai/dsh-persona' - config: - suffix: Your working directory is {{cwd}}. - prefix: >- - You are a coding agent powered by the {{model}} model. - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -# ── shell ─────────────────────────────────────────────────────────────────── - -# `shell-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. Both shell tools consume the host -# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are -# host-plane too. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - disabled: !!js process.platform === 'win32' - -- id: tool-pwsh - name: '@deepseek-ai/dsh-tool-pwsh' - disabled: !!js process.platform !== 'win32' - -# ── filesystem ────────────────────────────────────────────────────────────── - -# Both register into the host `tools` registry and provide nothing, so -# they need no realm. The `fs` service and its policy stay in the host. -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - sampleOverCapGlobResults: false - -# ── background jobs ──────────────────────────────────────────────────────── - -# Only the model-facing controls. The task REGISTRY stays on the host plane: -# its producers sit outside any realm this file could put it in — `tool-bash` -# above resolves it with `ctx.get`, and an entry-local realm here is invisible -# to every sibling row, so `run_in_background` would answer "background jobs -# unavailable" while these controls sat in the catalog. The registry is keyed by -# owning agent anyway, so one host instance serves every session. What a preset -# chooses is whether its agent can collect and stop background work at all. -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - -# ── skills ────────────────────────────────────────────────────────────────── - -# The skill REGISTRY lives in the host composition and is layered per scope: -# these rows register into THIS preset's layer of it, so they need no realm. -# `skill-filesystem` contributes local-root discovery for agents on this preset, and -# `tool-skill` gives them the catalog and loader; the merged catalog also -# carries whatever the deployment registered globally (repository plugins). -- id: skill-filesystem - name: '@deepseek-ai/dsh-skill-filesystem' - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -# ── goals ─────────────────────────────────────────────────────────────────── - -# The goal service and session driver stay on the host plane, where the Gateway -# can resolve them. The human command and model-facing tool register into this -# preset's scoped layers. -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - -# ── plan mode ─────────────────────────────────────────────────────────────── - -# Plan state is per-agent by nature, so an entry-local realm is not a -# workaround here — it is the correct lifetime. -- id: planning - name: cordis:group - group: true - isolate: - planMode: true - config: - - id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# ── compaction ────────────────────────────────────────────────────────────── - -# `compaction-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must -# share this realm rather than sit outside it. -# -# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST -# plane, and the rows here resolve that one instance. It takes no configuration, -# keys every fold by Session, and owns the context-meter projection units the -# browser reads for every session — behind a realm those units would come and go -# with whichever presets happen to be mounted. What a preset chooses is whether -# its agent compacts at all, which is `compaction-basic` below. -- id: compaction - name: cordis:group - group: true - isolate: - compaction: true - toolResultPruner: true - config: - - id: compaction-basic - name: '@deepseek-ai/dsh-compaction-basic' - - - id: command-compact - name: '@deepseek-ai/dsh-command-compact' - - - id: tool-result-pruner - name: '@deepseek-ai/dsh-compaction-tool-result-pruner' - config: - thresholdChars: 8192 - headChars: 4096 - tailChars: 1024 - -# ── delegation and workflows ──────────────────────────────────────────────── - -# The `subagents` registry and its spawn/fork backends live in the HOST -# composition: the registry is a process singleton whose cross-session queries -# the api-proxy serves to the browser, and a provider name may only be -# registered once. This preset contributes the delegation TOOLS, which resolve -# that host registry. -# -# `workflows` is different — nothing outside an agent reads it — so every row -# that reaches it shares one entry-local realm here, and a consumer left -# outside would resolve a host registry this preset does not populate. -- id: delegation - name: cordis:group - group: true - isolate: - workflowEngine: true - config: - - id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - - - id: tool-subagent-list-agents - name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' - - - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - modelSelectionSettings: true - backgroundMode: continuable - - # Fork omits model selection so provider/model stay equal to the parent and - # the inherited history remains eligible for KV Cache reuse. This preset - # keeps fork continuable; parent and child inherit the same messaging tool, - # while the parent id and return guidance follow the inherited history. - - id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: continuable - - # Production dsh does not install these optional providers. Install the - # matching Bundle in this Profile and restart the Host, then copy this - # preset and remove `disabled` from the matching tool row. Host availability - # alone grants no tool. - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - backgroundMode: one-shot - maxDepth: provider-managed - - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - backgroundMode: one-shot - maxDepth: provider-managed - - - id: workflow-ptc - name: '@deepseek-ai/dsh-workflow-ptc' - # No consumer here: `tool-workflow` is off in PTC mode and `tool-ralph` - # below is off by default. Restore this row together with `tool-ralph`. - disabled: true - config: - provider: spawn - - - id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - # Do not publish a second model-authored orchestration surface beside - # `run_code` in PTC mode. - disabled: true - - # Off by default with the base row it mirrors. Preset files take no patches, - # so a session that wants it duplicates this preset under a new id and drops - # `disabled` from this row and from the engine above; the shipped root - # shadows a copy that reuses this id. - - id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - disabled: true - config: - subagentProvider: spawn - maxRounds: 64 - -# ── remaining model-facing rows ───────────────────────────────────────────── - -- id: tool-ask-user - name: '@deepseek-ai/dsh-tool-ask-user' - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true - -# The `web` service and its search provider stay in the host composition; only -# the model-facing tool is per-session. -- id: tool-web - name: '@deepseek-ai/dsh-tool-web' - config: - fetch: true - searchTimeoutMs: 60000 - -# ── presentation ──────────────────────────────────────────────────────────── - -# PTC mode for this agent alone. The row waits for the host's `ptcRuntime` -# rather than assuming it: a deployment that composes no TypeScript runtime -# fails this preset at mount, naming this id, instead of at the first request. -- id: tool-presentation - name: '@deepseek-ai/dsh-agent-tool-presentation' - config: - mode: ptc - -- id: present - name: '@deepseek-ai/dsh-tool-present' - -- id: tool-plugin-manager - name: '@deepseek-ai/dsh-plugin-manager/tools' - disabled: true diff --git a/packages/preset/agent-presets/presets/ptc/preset.yml b/packages/preset/agent-presets/presets/ptc/preset.yml deleted file mode 100644 index 4cd0bed863..0000000000 --- a/packages/preset/agent-presets/presets/ptc/preset.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: PTC 模式 -description: 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 -order: 2 diff --git a/packages/preset/agent-presets/presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml deleted file mode 100644 index b4e81ec1e9..0000000000 --- a/packages/preset/agent-presets/presets/standard/agent.cordis.yml +++ /dev/null @@ -1,266 +0,0 @@ -# The `standard` agent preset: the full coding agent, mounted once per process. -# -# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a -# standing scope; every session naming it joins by scope parentage, so the -# tools and prompt sections registered here cover each joined agent while a -# session's own state stays keyed per Session/Agent inside the plugins. The -# host composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a -# preset must not own: the registries themselves, the sandbox and approval -# stack, persistence, and the model route. -# -# A service row here MUST sit inside a group carrying an `isolate` realm. -# Without one it publishes into the root realm, where it is process-global — -# another preset publishing the same name collides, and a host reader would -# resolve one preset's instance for every session; `dsh-agent-presets` rejects -# that at mount. `true` means an entry-local realm: this standing mount's own -# private instance, apart from every other preset's. (A shared label does NOT -# pool instances — `provide()` throws on the second registration under the -# same realm symbol; labels join REALMS, and are not what this file needs.) - -# ── identity ──────────────────────────────────────────────────────────────── - -# The preset's own persona, shadowing the deployment default for this agent. -# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. -- id: persona - name: '@deepseek-ai/dsh-persona' - config: - suffix: Your working directory is {{cwd}}. - prefix: >- - You are a coding agent powered by the {{model}} model. - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -# ── shell ─────────────────────────────────────────────────────────────────── - -# `shell-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. Both shell tools consume the host -# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are -# host-plane too. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - disabled: !!js process.platform === 'win32' - -- id: tool-pwsh - name: '@deepseek-ai/dsh-tool-pwsh' - disabled: !!js process.platform !== 'win32' - -# ── filesystem ────────────────────────────────────────────────────────────── - -# Both register into the host `tools` registry and provide nothing, so -# they need no realm. The `fs` service and its policy stay in the host. -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - sampleOverCapGlobResults: false - -# ── background jobs ──────────────────────────────────────────────────────── - -# Only the model-facing controls. The task REGISTRY stays on the host plane: -# its producers sit outside any realm this file could put it in — `tool-bash` -# above resolves it with `ctx.get`, and an entry-local realm here is invisible -# to every sibling row, so `run_in_background` would answer "background jobs -# unavailable" while these controls sat in the catalog. The registry is keyed by -# owning agent anyway, so one host instance serves every session. What a preset -# chooses is whether its agent can collect and stop background work at all. -- id: tool-jobs - name: '@deepseek-ai/dsh-tool-jobs' - -# ── skills ────────────────────────────────────────────────────────────────── - -# The skill REGISTRY lives in the host composition and is layered per scope: -# these rows register into THIS preset's layer of it, so they need no realm. -# `skill-filesystem` contributes local-root discovery for agents on this preset, and -# `tool-skill` gives them the catalog and loader; the merged catalog also -# carries whatever the deployment registered globally (repository plugins). -- id: skill-filesystem - name: '@deepseek-ai/dsh-skill-filesystem' - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -# ── goals ─────────────────────────────────────────────────────────────────── - -# The goal service and session driver stay on the host plane, where the Gateway -# can resolve them. The human command and model-facing tool register into this -# preset's scoped layers. -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - -# ── plan mode ─────────────────────────────────────────────────────────────── - -# Plan state is per-agent by nature, so an entry-local realm is not a -# workaround here — it is the correct lifetime. -- id: planning - name: cordis:group - group: true - isolate: - planMode: true - config: - - id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# ── compaction ────────────────────────────────────────────────────────────── - -# `compaction-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must -# share this realm rather than sit outside it. -# -# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST -# plane, and the rows here resolve that one instance. It takes no configuration, -# keys every fold by Session, and owns the context-meter projection units the -# browser reads for every session — behind a realm those units would come and go -# with whichever presets happen to be mounted. What a preset chooses is whether -# its agent compacts at all, which is `compaction-basic` below. -- id: compaction - name: cordis:group - group: true - isolate: - compaction: true - toolResultPruner: true - config: - - id: compaction-basic - name: '@deepseek-ai/dsh-compaction-basic' - - - id: command-compact - name: '@deepseek-ai/dsh-command-compact' - - - id: tool-result-pruner - name: '@deepseek-ai/dsh-compaction-tool-result-pruner' - config: - thresholdChars: 8192 - headChars: 4096 - tailChars: 1024 - -# ── delegation and workflows ──────────────────────────────────────────────── - -# The `subagents` registry and its spawn/fork backends live in the HOST -# composition: the registry is a process singleton whose cross-session queries -# the api-proxy serves to the browser, and a provider name may only be -# registered once. This preset contributes the delegation TOOLS, which resolve -# that host registry. -# -# `workflows` is different — nothing outside an agent reads it — so every row -# that reaches it shares one entry-local realm here, and a consumer left -# outside would resolve a host registry this preset does not populate. -- id: delegation - name: cordis:group - group: true - isolate: - workflowEngine: true - config: - - id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - - - id: tool-subagent-list-agents - name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' - - - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - modelSelectionSettings: true - backgroundMode: continuable - - # Fork omits model selection so provider/model stay equal to the parent and - # the inherited history remains eligible for KV Cache reuse. This preset - # keeps fork continuable; parent and child inherit the same messaging tool, - # while the parent id and return guidance follow the inherited history. - - id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: continuable - - # Production dsh does not install these optional providers. Install the - # matching Bundle in this Profile and restart the Host, then copy this - # preset and remove `disabled` from the matching tool row. Host availability - # alone grants no tool. - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - backgroundMode: one-shot - maxDepth: provider-managed - - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - backgroundMode: one-shot - maxDepth: provider-managed - - - id: workflow-ptc - name: '@deepseek-ai/dsh-workflow-ptc' - config: - provider: spawn - - - id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - - # Off by default with the base row it mirrors: the tool description - # restricts `ralph` to runs the human explicitly asked for, and completion - # is a worker self-report, not an independent evaluation. Preset files take - # no patches, so a session that wants it duplicates this preset under a new - # id and drops `disabled`; the shipped root shadows a copy that reuses this - # id. - - id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - disabled: true - config: - subagentProvider: spawn - maxRounds: 64 - -# ── remaining model-facing rows ───────────────────────────────────────────── - -- id: tool-ask-user - name: '@deepseek-ai/dsh-tool-ask-user' - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true - -# The `web` service and its search provider stay in the host composition; only -# the model-facing tool is per-session. -- id: tool-web - name: '@deepseek-ai/dsh-tool-web' - config: - fetch: true - searchTimeoutMs: 60000 - -- id: present - name: '@deepseek-ai/dsh-tool-present' - -- id: tool-plugin-manager - name: '@deepseek-ai/dsh-plugin-manager/tools' - disabled: true diff --git a/packages/preset/agent-presets/presets/standard/preset.yml b/packages/preset/agent-presets/presets/standard/preset.yml deleted file mode 100644 index caeacd1c1e..0000000000 --- a/packages/preset/agent-presets/presets/standard/preset.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: 标准模式 -description: 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 -order: 1 diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts deleted file mode 100644 index 5053f2aff3..0000000000 --- a/packages/preset/agent-presets/src/authoring.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Copying, reading, and deleting locally authored presets. - * - * Authoring is confined to a `user` root: the shipped `.system` set is part of - * the deployment, and letting a browser rewrite it would turn "reset to a known - * preset" into something the same caller could have broken first. - * - * The only authoring write is a whole-directory copy of an existing preset. - * No caller supplies composition text: the inputs are ids the host resolves - * against its own roots plus an optional display name, so authoring grants no - * capability the copied preset did not already carry. - * @module @deepseek-ai/dsh-agent-presets/authoring - */ - -import { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises' -import { dirname, isAbsolute, join, resolve } from 'node:path' -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { expandHomePath } from '@deepseek-ai/dsh-home-paths' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' -import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' - -/** - * Refuse one authoring request the deployment does not allow. - * @param presetId - what the caller tried to change, for the diagnostic. - * @param reason - why authoring is refused. - * @returns the failure to throw. - */ -function notWritable(presetId: string, reason: string): RemoteError<'agent-preset/read-only'> { - return new RemoteError( - 'agent-preset/read-only', - `agent-presets: preset "${presetId}" cannot be written: ${reason}`, - { agentPreset: presetId, reason }, - ) -} - -/** - * Refuse a copy onto an id something already occupies. Both the roster check - * and the on-disk check answer with it, so a taken id reads the same either way. - * @param presetId - the id that is already taken. - * @returns the failure to throw. - */ -export function presetExists(presetId: string): RemoteError<'agent-preset/invalid'> { - const reason = `preset "${presetId}" already exists — ` - + 'a copy never overwrites; delete the existing preset first or choose another id' - return new RemoteError('agent-preset/invalid', `agent-presets: ${reason}`, { agentPreset: presetId, reason }) -} - -/** - * The root locally authored presets are written to. - * @param roots - the configured roots in precedence order. - * @param presetId - the preset the caller is authoring, named by the refusal. - * @returns the absolute path of the first `user` root. - * @throws when the deployment configured no writable root. - */ -export function writableRoot(roots: readonly PresetRoot[], presetId: string): string { - const root = roots.find(candidate => candidate.trust === 'user') - if (root === undefined) { - throw notWritable(presetId, 'this deployment configures no user-writable preset root') - } - return resolve(expandHomePath(root.path)) -} - -/** - * Read one preset's composition text. - * @param preset - the resolved preset. - * @returns the file's contents. - */ -export async function readComposition(preset: AgentPreset): Promise { - return await readFile(preset.path, 'utf8') -} - -/** Whether anything occupies the path (cp's own errorOnExist backstops races). */ -async function occupied(path: string): Promise { - let present = true - try { - await stat(path) - } catch { - // Every stat failure means the same thing here: nothing usable occupies - // the path, so the copy may claim it. - present = false - } - return present -} - -/** - * Re-tighten a copied tree to owner-only. A shipped preset is world-readable - * in its install and `cp` preserves that; the copy carries the same weight as - * the settings document beside it, so group/other access is stripped. A - * file's owner-execute bit survives — a preset may ship runnable helpers. - */ -async function tightenModes(dir: string): Promise { - await chmod(dir, 0o700) - for (const entry of await readdir(dir, { withFileTypes: true })) { - const target = join(dir, entry.name) - if (entry.isDirectory()) { - await tightenModes(target) - } else { - /* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */ - await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) - } - } -} - -/** - * Create a preset by copying an existing one's whole directory. - * - * The copy carries everything the source directory holds — composition, - * metadata, skill directories, assets — because a preset is its directory, - * not one file. Symlinks are dereferenced so the copy is self-contained - * rather than a set of links back into the install it was copied from. - * - * The copied metadata is then rewritten: the source's description is kept - * (the file is the author's to edit afterwards), but its name and roster - * `order` are not — a copy presenting itself identically to its source, or - * sorted into the shipped set's declared order, would make the roster stop - * distinguishing them. With no name given and no description to keep, the - * file is removed so the copy publishes nothing rather than a blank. - * @param roots - the configured roots; the first `user` one receives the copy. - * @param source - the resolved preset the copy starts from. - * @param id - the new preset's id, which becomes its directory name. - * @param name - display name for the copy; omitted falls back to the id. - * @returns the absolute path of the new preset directory. - * @throws when the id is unusable or already occupied on disk, or the - * deployment configures no writable root. - */ -export async function copyComposition( - roots: readonly PresetRoot[], - source: AgentPreset, - id: string, - name?: string, -): Promise { - if (!PRESET_ID.test(id)) { - const reason = `preset id ${JSON.stringify(id)} must match ${String(PRESET_ID)} — ` - + 'the id is a directory name, so anything else could escape the preset root' - throw new RemoteError('agent-preset/invalid', `agent-presets: ${reason}`, { agentPreset: id, reason }) - } - const dir = join(writableRoot(roots, id), id) - // The roster check upstream only sees discovered presets; a directory with - // no composition file still occupies the name and deserves a readable - // refusal rather than a filesystem error code. - if (await occupied(dir)) throw presetExists(id) - try { - await cp(dirname(source.path), dir, { - recursive: true, dereference: true, force: false, errorOnExist: true, - }) - await tightenModes(dir) - const rendered = renderPresetMetadata({ - ...name === undefined ? {} : { name }, - ...source.description === undefined ? {} : { description: source.description }, - }) - const metadataPath = join(dir, METADATA_FILE) - if (rendered === undefined) { - await rm(metadataPath, { force: true }) - } else { - await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 }) - } - } catch (error) { - // A half-copied directory would be invisible to discovery at best and a - // mountable-but-incomplete preset at worst; a failed copy leaves nothing. - await rm(dir, { recursive: true, force: true }) - throw error - } - return dir -} - -/** - * Delete a locally authored preset. - * - * A shipped preset is refused: it belongs to the deployment. A preset a live - * session mounted is NOT refused — the composition was read at creation and is - * never re-read, so that session keeps running exactly as it was. - * @param roots - the configured roots. - * @param preset - the resolved preset to remove. - * @throws when the preset ships with the deployment or lies outside the writable root. - */ -export async function deleteComposition( - roots: readonly PresetRoot[], - preset: AgentPreset, -): Promise { - if (preset.trust !== 'user') { - throw notWritable(preset.id, 'it ships with the deployment') - } - const dir = join(writableRoot(roots, preset.id), preset.id) - // Belt and braces over the id pattern: the resolved directory must still be - // the one the writable root owns, whatever discovery reported. - if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) { - throw notWritable(preset.id, 'it does not live under the writable preset root') - } - await rm(dir, { recursive: true, force: true }) -} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts deleted file mode 100644 index 30daf0221e..0000000000 --- a/packages/preset/agent-presets/src/discovery.ts +++ /dev/null @@ -1,337 +0,0 @@ -/** - * Filesystem discovery of agent presets. A preset is a directory holding - * {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying - * its display text; the directory name is the preset id. Discovery - * re-reads the roots on every call so a preset authored while the process is - * running is visible without a restart. - * - * Discovery also owns preset HEALTH: a directory whose composition is - * missing or unloadable is reported as a broken roster row rather than - * skipped. A skipped directory would still occupy its id on disk — the copy - * path refuses the name while no surface shows anything to delete — and a - * malformed composition would otherwise read as an ordinary preset until the - * first session fails to mount it. - * - * Health is what every consumer reads before offering a preset — the pickers - * drop a broken row rather than defer the discovery to a failed session - * start — so it covers the way an authored preset actually rots: a row naming - * a package that was renamed or uninstalled. Resolving those names is a - * separate pass from the shape check and stops short of importing anything, - * so a composition is judged without running a line of plugin code. - * @module @deepseek-ai/dsh-agent-presets/discovery - */ - -import { existsSync } from 'node:fs' -import { readdir, readFile, stat } from 'node:fs/promises' -import { isBuiltin } from 'node:module' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { load } from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import { expandHomePath } from '@deepseek-ai/dsh-home-paths' -import { readPresetMetadata } from './metadata.ts' -import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' -import { classifyRowSpecifier, type RowSpecifier } from './specifier.ts' - -/** The composition file that makes a directory a preset. */ -export const COMPOSITION_FILE = 'agent.cordis.yml' - -/** - * Harness-home directory holding locally authored presets. - * - * This package owns the writable root the way `dsh-skill-filesystem` owns - * `/skills`: where a person's own presets go is the same place in - * every deployment that does not say otherwise, so a launcher that forgets to - * configure one still finds them. - * - * Package-internal on purpose: no consumer outside this package addresses the - * directory by name, and a test that imported it could not catch this value - * being wrong — the expected segment is spelled out where it is asserted. - */ -export const USER_PRESET_DIR = '.agent-presets' - -/** - * The shipped presets, bundled inside this package: the roster's built-in - * compositions travel with the machinery that mounts them, the way each - * preset's own skills travel inside its directory. Resolved relative to this - * module so both launch layouts work — `src/` under tsx and the bundled - * `lib/` sit one level below the package root. - */ -export const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../presets/', import.meta.url)) - -/** - * Why `rows` cannot be an entry list, or undefined when it can. - * - * A shallow shape check, deliberately short of the loader's work: it does not - * resolve plugin names or apply configs. What it catches is the hand-edit - * that produces a file the loader cannot even begin with — and it must accept - * everything the loader accepts, which is why rows are only required to be - * maps carrying a plugin `name` (groups recurse into their own lists). - * - * Shared with the composition inventory, whose file reads race edits against - * the health verdict and must judge the raced content by the same rule. - * @param rows - the parsed composition document. - * @param at - row-path prefix for nested diagnostics, empty at the top level. - * @returns one human-readable reason, or undefined when the shape holds. - */ -export function entryListProblem(rows: unknown, at = ''): string | undefined { - if (!Array.isArray(rows)) { - return at === '' - ? 'the composition must be a top-level list of plugin rows' - : `group ${at} must hold a list of plugin rows` - } - for (const [index, row] of rows.entries()) { - const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}` - if (typeof row !== 'object' || row === null || Array.isArray(row)) { - return `${label} is not a plugin row (expected a map with a "name")` - } - const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown } - if (typeof name !== 'string' || name === '') { - return `${label} names no plugin (a "name" string is required)` - } - if (group === true) { - const nested = entryListProblem(config, label) - if (nested !== undefined) return nested - } - } - return undefined -} - -/** Package lookup injected into preset discovery. */ -type PackageResolves = (specifier: string, base: string) => boolean - -/** - * Whether a package specifier is installed above `base` without importing it. - * - * The direct disk walk accepts unexported subpaths and rejects stale links - * whose package directory no longer exists. - * @param name - package specifier, possibly carrying a subpath. - * @param base - directory URL whose ancestors contain candidate `node_modules` directories. - * @returns true when the package is installed. - */ -function packageInstalled(name: string, base: string): boolean { - const pkg = name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/') - let dir = fileURLToPath(base) - for (;;) { - if (existsSync(join(dir, 'node_modules', pkg, 'package.json'))) return true - const parent = dirname(dir) - if (parent === dir) return false - dir = parent - } -} - -/** - * Whether one classified row names a module that exists, importing nothing. - * - * Package rows delegate to the injected lookup. Relative and `file:` rows use - * file metadata. No check evaluates the named module. - * @param row - the classified specifier, from {@link classifyRowSpecifier}. - * @param presetBase - directory URL a preset-relative specifier resolves against. - * @param harnessBase - base URL a package name resolves against. - * @param resolves - package lookup selected by the owning caller. - * @returns true when the row names something that can be imported. - */ -async function rowResolves( - row: RowSpecifier, presetBase: string, harnessBase: string, resolves: PackageResolves, -): Promise { - if (row.kind === 'builtin') return true - if (row.kind === 'package') return isBuiltin(row.specifier) || resolves(row.specifier, harnessBase) - const url = row.kind === 'file' ? new URL(row.specifier) : new URL(row.specifier, presetBase) - return await isFile(fileURLToPath(url)) -} - -/** One row that names a module no resolver can find. */ -interface UnresolvableRow { - /** `row "id"`, or the row's position when it declares none. */ - readonly label: string - /** The specifier exactly as the row wrote it. */ - readonly name: string -} - -/** - * Rows whose module cannot be resolved. - * - * Only rows that will certainly be started are checked, and the test is the - * Loader's own: it starts a row when `Boolean(options.disabled)` is false, so - * `disabled: 0` names a row that DOES start and must be checked. A `!!js` - * expression is an object and therefore truthy, which skips exactly the rows - * whose value only the loader context can decide. Skipping those trades a - * missed name for the failure that matters more: calling a usable preset - * broken makes it unselectable and uncopyable, which is worse than reporting - * the same stale row at mount time as before. - * - * Shape is the caller's precondition: {@link entryListProblem} has already - * proven every row is a map carrying a `name` string, and groups recurse the - * same way it does. - * @param rows - the parsed composition rows. - * @param presetBase - directory URL a preset-relative specifier resolves against. - * @param harnessBase - base URL a package name resolves against. - * @param at - row-path prefix for nested diagnostics, empty at the top level. - * @returns one entry per unresolvable row, in composition order. - */ -async function unresolvableRows( - rows: readonly unknown[], - presetBase: string, - harnessBase: string, - resolves: PackageResolves, - at = '', -): Promise { - const found: UnresolvableRow[] = [] - for (const [index, entry] of rows.entries()) { - const row = entry as { id?: unknown; name: string; group?: unknown; config?: unknown; disabled?: unknown } - if (Boolean(row.disabled)) continue - const positional = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}` - if (row.group === true) { - found.push(...await unresolvableRows(row.config as readonly unknown[], presetBase, harnessBase, resolves, positional)) - continue - } - if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves)) continue - const label = typeof row.id === 'string' && row.id !== '' ? `row "${row.id}"` : positional - found.push({ label, name: row.name }) - } - return found -} - -/** - * Why the composition at `path` cannot mount, or undefined when it looks - * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema}, - * the one carrying `!!js`), so health can never call a composition broken - * that the loader would accept. - * A package-lookup failure becomes this composition's broken reason, so one - * preset cannot abort discovery of the rest of the roster. - * @param path - absolute path of the composition file. - * @param harnessBase - base URL a row's package name resolves against. - * @returns one human-readable reason, or undefined when the file is loadable. - */ -async function compositionProblem( - path: string, harnessBase: string, resolves: PackageResolves, -): Promise { - let content: string - try { - content = await readFile(path, 'utf8') - } catch { - // The caller statted this file moments ago; any read failure now — - // deleted in between, permissions — is the same answer as unparsable. - return `the composition file ${COMPOSITION_FILE} cannot be read` - } - let rows: unknown - try { - rows = load(content, { schema: entryListSchema }) - } catch (error) { - /* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */ - const full = error instanceof Error ? error.message : String(error) - // First line only: js-yaml appends a multi-line code-frame snippet, and - // the reason is displayed on a roster card, not in a terminal. - return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}` - } - const shape = entryListProblem(rows) - if (shape !== undefined) return shape - // The composition's own directory, exactly as `Include` derives it, so a - // row naming a file the preset ships resolves the way the mount will. - const presetBase = new URL('.', pathToFileURL(path)).href - let unresolvable: UnresolvableRow[] - try { - unresolvable = await unresolvableRows(rows as readonly unknown[], presetBase, harnessBase, resolves) - } catch (error) { - const full = error instanceof Error ? error.message : String(error) - return `the composition's plugins cannot be checked: ${full.replace(/\n[\s\S]*$/, '')}` - } - const [first] = unresolvable - if (first === undefined) return undefined - if (unresolvable.length === 1) { - return `${first.label} names a plugin that cannot be resolved: ${first.name}` - } - return `${String(unresolvable.length)} rows name plugins that cannot be resolved:\n` - + unresolvable.map(row => `- ${row.label}: ${row.name}`).join('\n') -} - -/** - * Whether `path` names an existing regular file. - * @param path - absolute path to test. - * @returns true when the path resolves to a file. - */ -async function isFile(path: string): Promise { - try { - return (await stat(path)).isFile() - } catch { - // Any stat failure — absent, unreadable, a dangling link — means this - // directory does not present a composition, which is not an error: the - // directory simply is not a preset. - return false - } -} - -/** - * Scan one root for preset directories. - * - * An absent root yields no presets rather than throwing: the user root does - * not exist until the first locally authored preset, and naming a default - * that no root supplies already fails loud at resolution. - * - * Every directory whose name is a usable preset id is a roster row — broken - * when its composition is missing or unloadable. A directory named outside - * {@link PRESET_ID} is skipped instead: no copy could ever claim that name, - * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken - * presets would teach users to ignore the marker. - * @param root - the directory and the trust its presets inherit. - * @param harnessBase - base URL a row's package name resolves against; the - * caller's own `ctx.baseUrl`, which is where the installed harness lives. - * @param resolves - package-presence lookup for the active runtime. - * @returns the root's presets ordered by id. - */ -export async function scanRoot( - root: PresetRoot, harnessBase: string, resolves: PackageResolves = packageInstalled, -): Promise { - const dir = resolve(expandHomePath(root.path)) - let children - try { - children = await readdir(dir, { withFileTypes: true }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error }) - } - const found: AgentPreset[] = [] - for (const child of children) { - if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue - const directory = join(dir, child.name) - const path = join(directory, COMPOSITION_FILE) - const broken = await isFile(path) - ? await compositionProblem(path, harnessBase, resolves) - : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file` - // Display text only, and never fatal: a preset with unreadable metadata - // still mounts, it just shows its id. - const metadata = await readPresetMetadata(directory) - found.push({ - id: child.name, trust: root.trust, path, ...metadata, - ...broken === undefined ? {} : { broken }, - }) - } - // Declared order first so the shipped set reads by capability; everything - // else falls back to the id, which keeps authored presets stable. - return found.sort((left, right) => { - const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY) - return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder - }) -} - -/** - * Scan every root in precedence order. - * @param roots - roots in precedence order; an earlier root wins a duplicate id. - * @param harnessBase - base URL a row's package name resolves against. - * @param resolves - package-presence lookup for the active runtime. - * @returns every discovered preset, first-root-wins per id. - */ -export async function discoverPresets( - roots: readonly PresetRoot[], - harnessBase: string, - resolves: PackageResolves = packageInstalled, -): Promise { - const byId = new Map() - for (const root of roots) { - for (const preset of await scanRoot(root, harnessBase, resolves)) { - if (byId.has(preset.id)) continue - byId.set(preset.id, preset) - } - } - return [...byId.values()] -} diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts deleted file mode 100644 index 270cc43082..0000000000 --- a/packages/preset/agent-presets/src/index.ts +++ /dev/null @@ -1,862 +0,0 @@ -/** - * Agent presets: each session composes its model-facing plugin set from one - * preset `cordis.yml`, mounted ONCE per preset under a standing scope and - * joined by every agent that names it. - * - * The standing mount is what makes a preset one composition rather than one - * per session: its plugin instances, tool registrations, prompt sections, and - * projection units exist exactly once, keyed per session inside the plugins - * themselves (they predate presets and were written for a shared world). An - * agent joins by having its scope key parented to the mount's - * ({@link bindScopeParent}), which makes the mount's registrations visible to - * that agent's views and the mount's listeners receive that agent's events — - * and a host reader with no agent at all (a cold transcript read) resolves - * the same standing registrations by preset id. - * - * This package owns the preset vocabulary, filesystem discovery, and the - * guarded standing mount. It does not decide when an agent is created — the - * agent factory's `setup(agentCtx)` hook is the one supported call site, - * because only there is the join installed while the agent is still - * unpublished, so a rejected composition rolls the whole creation back. - * @module @deepseek-ai/dsh-agent-presets - */ - -import { stat } from 'node:fs/promises' -import { Context } from '@deepseek-ai/cordis' -import { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import z from '@deepseek-ai/schemastery' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -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 type {} from '@deepseek-ai/dsh-app-boot' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentPresetDocument, AgentPresetRoster } from './types.ts' -import type {} from '@deepseek-ai/dsh-session-projection' -// Type-only: resolves the registry notification emitted after scope reparenting. -import type {} from '@deepseek-ai/dsh-tools' -import type SettingsService from '@deepseek-ai/dsh-settings' -import type { SettingsScope } from '@deepseek-ai/dsh-settings' -import { dshHomePath } from '@deepseek-ai/dsh-home-paths' -import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' -import { copyComposition, deleteComposition, presetExists, readComposition } from './authoring.ts' -import { livePresetMounts, mountPreset, serviceForAgent, standingMountFor } from './mount.ts' -import { - fileComposition, mountedCompositionRows, - type AgentPresetComposition, -} from './composition-inventory.ts' -import type { AgentPreset, Config, PresetRoot } from './preset.ts' -import { agentPresetProjectionDefinition } from './session.ts' -export type * from './types.ts' -export type { - AgentPresetComposition, AgentPresetCompositionRow, CompositionRowEnablement, -} from './composition-inventory.ts' - -/** Settings namespace carrying the user's preset-picker preference and chosen default. */ -export const SETTINGS_NAMESPACE = 'agent-presets' - -/** Refuse an empty preset id before invoking a domain operation. */ -function validatePresetId(value: string, field: 'agentPreset' | 'from'): void { - if (value.length === 0) { - throw new RemoteError('gateway/bad-request', `${field} must be a non-empty string`, {}) - } -} - -/** Resolved preset-selection settings; the registration base supplies both fields. */ -export interface AgentPresetSettings { - /** Saved default used when mode selection is enabled. */ - default: string - /** Whether visible mode selection and the saved user default govern unnamed new sessions. */ - modeSelectionEnabled: boolean -} - -/** Runtime schema for the user-writable slice. */ -export const AgentPresetSettingsSchema: z = z.object({ - default: z.string(), - modeSelectionEnabled: z.boolean(), -}) - -export { COMPOSITION_FILE, discoverPresets, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts' -export { - METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, -} from './metadata.ts' -export { - inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, - type JoinedPresetMount, type PresetMount, -} from './mount.ts' -export { copyComposition, deleteComposition, readComposition, writableRoot } from './authoring.ts' -export { agentPresetProjectionDefinition } from './session.ts' -export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' - -declare module '@deepseek-ai/cordis' { - interface Context { - agentPresets: AgentPresets - } -} - -/** - * Registry over the deployment's agent presets. - * - * 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. - */ -export class AgentPresets extends TypertRemoteService { - static inject = ['loader', 'sessionProjections'] - - /** Runtime schema for the preset roster. */ - static Config = z.object({ - default: z.string().required(), - roots: z.array(z.object({ - path: z.string().required(), - trust: z.union(['system', 'user'] as const).default('user'), - })).default([]), - includeShippedRoot: z.boolean().default(true), - includeUserRoot: z.boolean().default(true), - }) as z - - /** - * The roots discovery and authoring actually scan: the package's shipped - * root unless `includeShippedRoot` is false, then every configured root in - * order, then the harness-home user root unless `includeUserRoot` is false. - * - * Derived once, because a root set that changed between `list()` and the - * `copy()` acting on its answer would author into a directory the caller - * never saw. The shipped root comes FIRST and the user root LAST because an - * earlier root wins a duplicate id: a shipped preset shadows any directory - * that claimed its name, and a configured root still shadows a locally - * authored one. - */ - private readonly resolvedRoots: readonly PresetRoot[] - - /** - * Where a row's package name resolves from: the base URL of the composition - * this roster was loaded by, which is inside the installed harness. - * - * Discovery needs it because a preset's own directory is the wrong base for - * a package name — a locally authored preset lives under the user's home, - * where Node's upward `node_modules` walk never reaches the harness's - * dependencies. The mount already resolves rows this way; holding the same - * base here is what lets health answer the question before a session does. - */ - private readonly harnessBase: string - /** - * The user layer over `config.default`, present only while a settings - * provider is composed. Held rather than snapshotted so a hot-reloaded - * document takes effect without a restart. - */ - private settings: SettingsScope | undefined - - /** - * The settings service behind {@link settings}, held for the one write this - * service makes: clearing a user default it has just deleted. - */ - private settingsService: SettingsService | undefined - - /** - * The service's own untraced context. Methods invoked through the traceable - * proxy see `this.ctx` rebound to the CALLER's context, which carries a - * shadow; a subtree minted from it resolves every service through that - * shadow's fiber instead of each entry's own inject store, so preset rows - * would fail on the very services they declare. Standing mounts must hang - * off the untraced original (the `jobs-local` selfCtx precedent). - */ - private readonly selfCtx: Context - - constructor(ctx: Context, public config: Config) { - super(ctx, 'agentPresets') - this.selfCtx = ctx - const { baseUrl } = ctx - if (baseUrl === undefined) { - // Self-contained misconfiguration, so it fails at load: without a base - // the roster can neither resolve a row nor tell a healthy preset from - // one naming a package that is gone, and the silent alternative is the - // exact failure this check exists to report. - throw new Error( - 'agent-presets: the roster needs `ctx.baseUrl` to resolve the plugins a composition names; ' - + 'compose it under a Loader, or set the base on the context this plugin is applied to', - ) - } - this.harnessBase = baseUrl - this.resolvedRoots = [ - ...config.includeShippedRoot ? [{ path: SHIPPED_PRESET_ROOT, trust: 'system' } satisfies PresetRoot] : [], - ...config.roots, - ...config.includeUserRoot ? [{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' } satisfies PresetRoot] : [], - ] - // Deliberately not `settings.installSection`: that method exists to re-judge - // what a consumer DERIVED from the source — memoized resolutions, - // registration-level facts — across attach, detach, and change. Nothing - // here is derived. `defaultId` reads through on every call, so both of its - // hooks would be no-ops and the source thunk would restate this field. - ctx.inject(['settings'], (settingsCtx) => { - this.settings = settingsCtx.settings.register( - SETTINGS_NAMESPACE, - AgentPresetSettingsSchema, - { base: { default: config.default, modeSelectionEnabled: true } }, - ) - this.settingsService = settingsCtx.settings - settingsCtx.effect(() => () => { - this.settings = undefined - this.settingsService = undefined - }, 'agentPresets.settings()') - }) - - ctx.sessionProjections.register(agentPresetProjectionDefinition) - - // 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). - // - // Known false positive: a session created bare and bound later by - // `recompose` is warned about once, before its first bind. Shipped Web - // sessions mount in `setup`, and children join through `composeFrom` - // before publication. - ctx.on('agent/created', ({ agent }) => { - if (this.resolvedRoots.length === 0) 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 ' - + '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)', - ) - }) - - // The durable record is the commit point. Its public notification carries - // only the stable identity needed by clients, never the live Session. - ctx.on('session/event', (session, event) => { - if (event.type !== 'agent-preset/selected') return - ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) - }) - } - - /** - * The preset id mounted when a caller names none. - * - * Read per call rather than cached: the settings document is hot-reloaded, so - * changing the default takes effect on the next session created and leaves - * every running session on the preset it was composed from. - */ - get defaultId(): string { - // Hiding the picker is also the product's safe-default boundary: a stale - // user choice from an older build must not silently compose a non-standard - // new session while there is no control that reports that choice. - return this.selectionPolicy().defaultId - } - - /** Read one internally consistent snapshot of the selection policy. */ - private selectionPolicy(): { enabled: boolean; defaultId: string } { - const settings = this.settings?.get() - if (settings === undefined) return { enabled: true, defaultId: this.config.default } - const enabled = settings.modeSelectionEnabled - return { - enabled, - defaultId: enabled ? settings.default : this.config.default, - } - } - - /** - * Every preset the configured roots currently supply. - * @returns the presets, first-root-wins per id. - */ - async list(): Promise { - const packages = this.ctx.get('pluginPackages') - return packages === undefined - ? await discoverPresets(this.resolvedRoots, this.harnessBase) - : await discoverPresets( - this.resolvedRoots, - this.harnessBase, - (specifier, base) => packages.packageOf(specifier, base) !== undefined, - ) - } - - /** - * The roster off the Host: {@link list} projected to path-free rows, with - * the policy-effective default marked, this deployment's authoring - * capability, and its mode-selection policy beside it. - * - * Whether a client can open a preset's directory is the Host's own opener - * capability, not a roster property — a caller needing both joins them. - * @returns the rows, authoring capability, and effective selection policy. - */ - @Remote('list') - async remoteExportList(): Promise { - // Keep the visible policy and marked default from the same settings - // snapshot even when discovery yields while settings are hot-reloaded. - const policy = this.selectionPolicy() - const presets = await this.list() - return { - presets: presets.map(preset => ({ - id: preset.id, - trust: preset.trust, - isDefault: preset.id === policy.defaultId, - ...preset.name === undefined ? {} : { name: preset.name }, - ...preset.description === undefined ? {} : { description: preset.description }, - ...preset.broken === undefined ? {} : { broken: preset.broken }, - })), - authorable: this.authorable, - modeSelectionEnabled: policy.enabled, - } - } - - /** - * Every preset's composition as flattened plugin rows, for plugin-listing - * surfaces beside the roster's own picker. - * - * A preset with a live standing mount answers from its newest generation's - * Loader entries — the composition new sessions join — even when the file - * behind it has since been edited into an unreadable state: the mount is - * what sessions actually run, so the broken verdict only applies to a - * preset nothing composed. One never composed since boot answers from its - * file, with `!!js` disabled gates evaluated against the Loader context so - * both answers reflect the same host. Reading never mounts: an unmounted - * preset is parsed, not composed, so listing a preset's plugins cannot - * activate them early. A composition that stopped reading between - * discovery's health verdict and this read is reported broken with the - * raced reason rather than dropped. - * @returns one composition per roster preset, in roster order. - */ - async compositionInventory(): Promise { - const defaultId = this.defaultId - // The Loader's own expression scope: what a mount decision would consult. - // An identifier this scope cannot resolve throws under `with`, and the - // row stays `'conditional'`; only a gate whose identifiers resolve BOTH - // here and under a mounted row's entry chain, with different values, - // could report a wrong verdict — the shipped gates read `process` alone. - const evaluateExpression = (expression: string): unknown => evaluate(this.ctx.loader.ctx, expression) - // Mount records span every Cordis runtime in the process; only this - // runtime's mounts describe this roster's presets. - const rootFiber = this.ctx.root.fiber - const found: AgentPresetComposition[] = [] - for (const preset of await this.list()) { - const identity = { - id: preset.id, - trust: preset.trust, - ...preset.name === undefined ? {} : { name: preset.name }, - isDefault: preset.id === defaultId, - } - // Before the broken verdict: a mounted preset whose file was since - // deleted or corrupted still runs its standing composition. Newest - // generation last: mount records keep insertion order, and a - // superseded generation's record precedes its replacement's. - const mount = livePresetMounts(rootFiber).findLast(candidate => candidate.presetId === preset.id) - if (mount !== undefined) { - found.push({ ...identity, rows: mountedCompositionRows(mount.tree) }) - continue - } - if (preset.broken !== undefined) { - found.push({ ...identity, broken: preset.broken, rows: [] }) - continue - } - const read = await fileComposition(preset.path, evaluateExpression) - found.push('broken' in read - ? { ...identity, broken: read.broken, rows: [] } - : { ...identity, rows: read.rows }) - } - return found - } - - /** - * Resolve one preset by id. - * - * A broken preset resolves — deleting one, reading one, and reporting one - * all need the row — and the mounting paths refuse it AFTER resolution - * through {@link resolveMountable}. - * @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 { - const wanted = id ?? this.defaultId - const presets = await this.list() - const found = presets.find(preset => preset.id === wanted) - if (found === undefined) { - const available = presets.map(preset => preset.id) - throw new RemoteError( - 'agent-preset/not-found', - `agent-presets: preset "${wanted}" not found (available: ${available.join(', ') || 'none'})`, - { agentPreset: wanted, available }, - ) - } - return found - } - - /** - * Resolve one preset that is about to compose an agent, refusing a broken - * one with its discovery-reported reason. Failing here rather than inside - * the loader keeps the answer the same for every unloadable shape — ghost - * directory, unparsable YAML, rowless list — and spends no mount attempt - * on a composition discovery already read as unusable. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the resolved, mountable preset. - * @throws when the preset is unknown or discovery reports it broken. - */ - private async resolveMountable(id?: string): Promise { - const preset = await this.resolve(id) - if (preset.broken !== undefined) { - throw new RemoteError( - 'agent-preset/invalid', - `agent-presets: preset "${preset.id}" failed to mount: ${preset.broken}`, - { agentPreset: preset.id, reason: preset.broken }, - ) - } - return preset - } - - /** - * Standing mounts by preset id, single-flight so two agents racing the - * first use of one preset share one composition. A settled failure is - * removed so a later session retries a preset whose file has been fixed; a - * settled success serves until the composition FILE visibly changes — each - * generation records its file stamp, and a stale stamp starts the next - * generation for sessions created afterwards. Sessions already joined keep - * the generation they run on; a superseded one is never disposed while the - * process lives (reclaimed only by whole-tree teardown), so editing files - * is bounded by how often compositions change, not by session count. - */ - private readonly standing = new Map>() - - /** - * Parent bindings of the agents this roster composed, keyed by the agent's - * scope key. The binding is dsh-scope's only re-link capability; holding it - * here makes this service the sole authority that can move an agent between - * standing compositions. WeakMap: entries die with their agents. - */ - private readonly bindings = new WeakMap() - - /** - * Compose one agent from a preset: ensure the preset's standing mount, then - * parent the agent's scope key to it so the mount's registrations and - * listeners cover this agent. - * - * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken preset never yields a half-composed - * session. - * @param agentCtx - the agent's scope context. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was composed, for the caller to record. - * @throws when the preset is unknown or its composition is unusable. - */ - async mount(agentCtx: Context, id?: string): Promise { - 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 preset = await this.resolveMountable(id) - const standing = await this.ensureStanding(preset) - // The one bind of this agent's ancestry. The binding is the only re-link - // authority, held privately so nothing outside this roster can move a - // composed agent to another preset; a later recompose layer re-links - // through it under the caller-owned blank-session contract. - this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) - 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 - } - - /** - * The roots this roster scans, which is not `config.roots`: the package's - * shipped root unless `includeShippedRoot` is false, every configured root - * in order, then the harness-home user root unless `includeUserRoot` is - * false. Read this — not the config field — to answer whether a roster is - * composed at all, so one derivation decides it. - */ - get roots(): readonly PresetRoot[] { - return this.resolvedRoots - } - - /** Whether this deployment has a root locally authored presets go to. */ - get authorable(): boolean { - return this.resolvedRoots.some(root => root.trust === 'user') - } - - /** - * Read one preset's composition text. - * @param id - the preset id. - * @returns the composition exactly as stored. - * @throws when no configured root supplies that id. - */ - async read(id: string): Promise { - return await readComposition(await this.resolve(id)) - } - - /** - * One preset's composition text with the roster row it belongs to. - * @param agentPreset - the preset id. - * @returns the composition beside its trust and published metadata. - * @throws {RemoteError} `gateway/bad-request` for an empty id, or - * `agent-preset/not-found` when no configured root supplies it. - */ - @Remote('read') - async readDocument(agentPreset: string): Promise { - validatePresetId(agentPreset, 'agentPreset') - const preset = await this.resolve(agentPreset) - return { - agentPreset: preset.id, - trust: preset.trust, - content: await this.read(preset.id), - ...preset.name === undefined ? {} : { name: preset.name }, - ...preset.description === undefined ? {} : { description: preset.description }, - } - } - - /** - * Create a locally authored preset by copying an existing one whole. - * - * Copy is the only authoring write. Composition text never crosses this - * seam: the source is named by id and its directory is copied as it stands, - * so the copy is exactly as loadable as its source and authoring grants no - * capability the roster did not already carry. The copy is NOT mounted to - * validate — a source that mounts today yields a copy that mounts today. - * @param from - the preset the copy starts from; shipped presets are the - * primary source, so any trust is accepted. - * @param id - the new preset's id, which becomes its directory name. - * @param name - display name for the copy; absent falls back to the id. - * @throws when the source is unknown, the id is unusable or already taken, - * or the deployment configures no writable root. - */ - async copy(from: string, id: string, name?: string): Promise { - const source = await this.resolve(from) - // The roster check refuses ids any root supplies — shipped ones included, - // since a user directory named like a shipped preset is shadowed by it. - // The disk check inside copyComposition only sees the writable root. - if ((await this.list()).some(preset => preset.id === id)) { - throw presetExists(id) - } - await copyComposition(this.resolvedRoots, source, id, name) - // A settled mount under this id can only be stale (its preset was deleted - // from disk outside `remove`); the new preset must not inherit it. Every - // session already joined keeps the generation it runs on regardless. - this.standing.delete(id) - } - - /** - * Copy one preset through the Remote API. - * @param from - the source preset id. - * @param id - the new preset id. - * @param name - the copy's optional display name. - * @returns once the copy is stored. - * @throws {RemoteError} with the corresponding stable preset code and - * details when the copy is refused. - */ - @Remote('copy') - async remoteExportCopy(from: string, id: string, name?: string): Promise { - validatePresetId(from, 'from') - validatePresetId(id, 'agentPreset') - await this.copy(from, id, name) - } - - /** - * Delete a locally authored preset. - * - * @param id - the preset id. - * @throws when the preset is unknown or ships with the deployment. - */ - async remove(id: string): Promise { - await deleteComposition(this.resolvedRoots, await this.resolve(id)) - // Sessions on the deleted preset keep their standing mount; only new - // sessions see the roster without it. - this.standing.delete(id) - // Storing a default that does not exist YET is deliberate — the roster is a - // live directory, so a name absent now may exist by the time a session asks - // for it, and `resolve` reports it then. A default this call just deleted is - // not that case: nothing will ever supply it again, and left in place every - // session created without an explicit pick would fail to start. Clearing it - // exposes the deployment's own default underneath, which is the layering. - if (this.settings?.get().default !== id) return - await this.settingsService?.mutate( - SETTINGS_NAMESPACE, - [{ op: 'unset', path: ['default'] }], - ) - } - - /** - * Delete one preset through the Remote API. - * @param id - the preset id. - * @returns once the preset is deleted. - * @throws {RemoteError} with the corresponding stable preset code and - * details when deletion is refused. - */ - @Remote('deletePreset') - async remoteExportDelete(id: string): Promise { - validatePresetId(id, 'agentPreset') - await this.remove(id) - } - - /** - * One agent's instance of a service its preset mounted. - * - * A preset publishes services behind `isolate` realms, which are invisible - * outside the group that declares them — including to the host. This is how a - * caller holding the agent reads one anyway: a request that is ABOUT a - * session but arrives from outside it, which is every browser RPC. - * - * Read addressing only. A host row that `inject`s a service cannot use this, - * because injection resolves before any session exists and has no agent to - * key by; such a service belongs on the host plane instead. - * @param agent - the agent whose composition to look inside. - * @param name - the service name as the preset's rows resolve it. - * @returns the agent's instance, or undefined when its preset mounts none. - */ - serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { - return serviceForAgent(this.ctx, agent, name) - } - - /** - * Re-link one agent to a different preset's standing composition. - * - * Only valid while the agent has produced nothing: swapping tools mid - * conversation would leave logged tool calls the new composition cannot - * make. The CALLER owns that check — this method does not read session - * history. - * - * The swap is a parent re-link, not an unmount: standing mounts are shared - * and permanent, so the old composition stays for its other agents and the - * new one is ensured BEFORE the link moves. An unknown or unusable preset - * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. The re-link runs through the binding this roster kept - * from the agent's mount — dsh-scope's only re-link authority. An agent - * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. A committed re-link emits - * `tools/change` because changing the parent scope changes the Agent's - * resolved tool set without adding or removing registry entries. - * @param agentCtx - the agent's scope context. - * @param id - the preset to compose the agent from instead. - * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable. - */ - async recompose(agentCtx: Context, id: string): Promise { - const agentKey = scopeOf(agentCtx) - if (agentKey === undefined) { - throw new Error('agent-presets: refusing to recompose an unscoped context') - } - const preset = await this.resolveMountable(id) - const standing = await this.ensureStanding(preset) - const binding = this.bindings.get(agentKey) - if (binding === undefined) { - this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) - } else { - binding.rebind(standing.key) - } - // Reparenting changes every scope-layered tool view without adding or - // removing a registration. Publish the registry's normal invalidation so - // Agent-owned overlays can reconcile with the new ancestry. - try { - this.ctx.emit('tools/change') - } catch (error: unknown) { - this.ctx.logger.warn(`agent-presets: tools/change listener failed after recomposing an Agent: ${String(error)}`) - } - return preset - } - - /** - * Serializes {@link select} per session. Two concurrent selects would both - * pass the blank check, and the second re-link would then find the record - * the first already replaced — leaving two compositions registered into one - * agent layer. A client's `busy` flag is not enforcement: the wire is - * reachable directly. - * - * Entries hold a failure-swallowing guard rather than the turn itself, so a - * refused switch does not reject the next caller's chain. - */ - private readonly switches = new Map>() - - /** - * Compose a blank session's agent from a different preset and record it. - * @param agent - the session's live agent, resolved from the wire identity. - * @param agentPreset - the preset to compose the agent from instead. - * @returns the preset id that was recorded. - * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, - * `agent-preset/not-found`, or `agent-preset/invalid` when refused. - */ - @Remote('select') - async select(agent: Agent, agentPreset: string): Promise { - validatePresetId(agentPreset, 'agentPreset') - const queued = this.switches.get(agent.id) ?? Promise.resolve() - const turn = queued.then(() => this.swap(agent, agentPreset)) - const guard = turn.catch(() => undefined) - this.switches.set(agent.id, guard) - try { - return await turn - } finally { - if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id) - } - } - - /** One queued switch: re-check, recompose, then record what the agent runs. */ - private async swap(agent: Agent, agentPreset: string): Promise { - // Re-read inside the queue: an earlier switch may have run, and a - // conversation may have started, since this call was queued. A turn is one - // model-loop execution; standalone plugin events never open one, so a - // session that has only run commands is still blank. - const boundary = this.selfCtx.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary !== undefined - && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { - throw new RemoteError( - 'agent-preset/locked', - `session "${agent.id}" has already started; its agent preset is fixed`, - { sessionId: agent.id, agentPreset }, - ) - } - const preset = await this.recompose(agent.ctx, agentPreset) - // Recorded only after the swap committed: the log states what the agent - // runs, and a rejected mount leaves the previous composition. - agent.session.append('agent-preset/selected', { agentPreset: preset.id }) - return preset.id - } - - /** - * The standing scope key of one preset, for a host reader with no agent. - * - * A cold transcript read resolves tool presenters against the composition - * the session recorded, and the standing mount makes that possible without - * resuming anything: ensuring the mount composes plugins but starts no - * agent, no session, and no turn. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the standing scope key readers pass as a registry view scope. - * @throws when the preset is unknown or its composition is unusable. - */ - async standingKeyFor(id?: string): Promise { - const preset = await this.resolveMountable(id) - return (await this.ensureStanding(preset)).key - } - - /** Resolve (or create, single-flight) the standing mount of one preset. */ - private async ensureStanding(preset: AgentPreset): Promise { - const pending = this.standing.get(preset.id) - if (pending !== undefined) { - const mounted = await pending - // Files are the only composition editor (authoring is copy/delete), so - // the stamp is what notices an edit: a changed file starts the next - // generation here, for this and later sessions. An unreadable stamp - // serves the current generation — a mount must survive its file - // disappearing, and failing the session over a stat would not. - const current = await compositionStamp(preset.path) - if (current === undefined || sameStamp(mounted.stamp, current)) return mounted - // TODO: reclaim the superseded generation once the last agent joined to - // it is gone. The subtree is not inert — `dsh-skill-filesystem` watches its - // roots — and the settings-page authoring flow turns "a composition - // changed" into a per-save event. This needs a joined-agent count on - // StandingMount, incremented in `mount`/`composeFrom`/`recompose` and - // decremented when the agent's scope key dies. - // Guarded delete: a caller that raced this one may have already started - // the next generation, and dropping THAT pointer would fork a third. - if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id) - return this.ensureStanding(preset) - } - const created = (async (): Promise => { - const key: ScopeKey = { agentPreset: preset.id } - const scope = createScope(this.selfCtx, key) - try { - // Stamped before the file is read: an edit racing the mount makes the - // stamp stale rather than silently current, so the next session - // refreshes instead of trusting a composition older than its stamp. - const stamp = await compositionStamp(preset.path) - if (stamp === undefined) { - const reason = `composition file is unreadable: ${preset.path}` - throw new RemoteError( - 'agent-preset/invalid', - `agent-presets: preset "${preset.id}" failed to mount: ${reason}`, - { agentPreset: preset.id, reason }, - ) - } - await mountPreset(scope.ctx, preset) - return { key, scope, stamp } - } catch (error) { - this.standing.delete(preset.id) - await scope.dispose() - throw error - } - })() - this.standing.set(preset.id, created) - return created - } -} - -/** The composition file identity one standing generation was mounted from. */ -interface CompositionStamp { - /** Modification time in milliseconds, as `stat` reports it. */ - readonly mtimeMs: number - /** File size in bytes, the tiebreak for edits within one mtime tick. */ - readonly size: number -} - -/** Read one composition file's stamp, or undefined when it cannot be statted. */ -async function compositionStamp(path: string): Promise { - try { - const { mtimeMs, size } = await stat(path) - return { mtimeMs, size } - } catch { - // Deleted, replaced by an unreadable entry, or otherwise unstattable all - // mean the same to the caller: the file offers no identity to compare. - return undefined - } -} - -/** Whether two stamps name the same file state. */ -function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean { - return a.mtimeMs === b.mtimeMs && a.size === b.size -} - -/** One preset's standing composition. */ -interface StandingMount { - /** Scope key agents are parented to; also the mount's registration scope. */ - readonly key: ScopeKey - /** Disposal boundary; held for whole-tree teardown, never per-session. */ - readonly scope: Scope - /** Stamp of the composition file this generation was mounted from. */ - readonly stamp: CompositionStamp -} - -export default AgentPresets diff --git a/packages/preset/agent-presets/src/metadata.ts b/packages/preset/agent-presets/src/metadata.ts deleted file mode 100644 index aa964e0ada..0000000000 --- a/packages/preset/agent-presets/src/metadata.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * A preset's display metadata: the name and description a picker shows. - * - * It lives in its own file because the composition is a top-level list of - * plugin rows — YAML cannot carry sibling keys beside it, and faking a - * metadata row would hand the Loader something to load. Keeping it separate - * also keeps the composition exactly what its name says: a Cordis file the - * loader owns and the cordis preset can author. - * - * The file carries display text ONLY. `id` is the directory name and `trust` - * comes from the root a preset was discovered under, so neither is writable - * here — otherwise a locally authored preset could claim to be a shipped one. - * - * Every read failure degrades to no metadata. A preset whose display text is - * missing, malformed, or unreadable still mounts: presentation is not a - * capability, and a broken name must never become an agent that cannot start. - * @module @deepseek-ai/dsh-agent-presets/metadata - */ - -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import yaml from 'js-yaml' - -/** The optional display-metadata file beside a preset's composition. */ -export const METADATA_FILE = 'preset.yml' - -/** Display text a preset may publish about itself. */ -export interface PresetMetadata { - /** Human-facing name; falls back to the preset id when absent. */ - readonly name?: string - /** One sentence on what this preset is for. */ - readonly description?: string - /** - * Position within its group; lower comes first. A preset that declares - * none sorts after every preset that does, then by id — so the shipped set - * can read in capability order while authored ones stay alphabetical. - */ - readonly order?: number -} - -/** A non-empty trimmed string, or undefined for anything else. */ -function text(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed === '' ? undefined : trimmed -} - -/** - * Read one preset directory's display metadata. - * - * Absent, unparsable, and wrongly-shaped files are all the same answer — - * empty metadata — because the caller renders a picker, not a diagnostic. - * @param directory - the preset directory. - * @returns the display text the preset published, possibly empty. - */ -export async function readPresetMetadata(directory: string): Promise { - let raw: string - try { - raw = await readFile(join(directory, METADATA_FILE), 'utf8') - } catch { - // Absent is the common case: metadata is optional and most presets, - // including every one authored by duplicating another, carry none. - return {} - } - let parsed: unknown - try { - parsed = yaml.load(raw) - } catch { - // Malformed display text is not worth failing discovery over; the picker - // falls back to the id, and the composition still mounts. - return {} - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} - const record = parsed as Record - const name = text(record.name) - const description = text(record.description) - const order = typeof record.order === 'number' && Number.isFinite(record.order) - ? record.order - : undefined - return { - ...name === undefined ? {} : { name }, - ...description === undefined ? {} : { description }, - ...order === undefined ? {} : { order }, - } -} - -/** - * Render display metadata as the file's contents. - * - * Absent fields are omitted rather than written empty, so a preset with no - * description does not ship a key that reads as an intentional blank. - * @param metadata - the display text to store. - * @returns the YAML document, or undefined when there is nothing to store. - */ -export function renderPresetMetadata(metadata: PresetMetadata): string | undefined { - const name = text(metadata.name) - const description = text(metadata.description) - const { order } = metadata - if (name === undefined && description === undefined && order === undefined) return undefined - return yaml.dump({ - ...name === undefined ? {} : { name }, - ...description === undefined ? {} : { description }, - ...order === undefined ? {} : { order }, - }, { lineWidth: -1 }) -} diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts deleted file mode 100644 index e759230505..0000000000 --- a/packages/preset/agent-presets/src/mount.ts +++ /dev/null @@ -1,424 +0,0 @@ -/** - * Mount one preset composition under an agent's scope context, then prove the - * result is usable before the agent is published. - * - * The scope context is what makes the composition per-session: entry contexts - * chain to the context the subtree was plugged into, so every `ctx.tools` - * and `ctx.systemPrompt` registration inside the preset files into that - * agent's layer and unwinds with it. Two guards make that safe. A row that - * never reached a usable state is rejected, because a directly-plugged subtree - * is absent from `ctx.loader.entries()` and no boot audit covers it. A row that - * published a service into the ROOT realm is rejected, because such a service - * is process-global rather than per-session and the second session mounting the - * same preset collides with the first. - * @module @deepseek-ai/dsh-agent-presets/mount - */ - -import { pathToFileURL } from 'node:url' -import { Context, type Fiber } from '@deepseek-ai/cordis' -import { Include } from '@deepseek-ai/cordis-plugin-include' -import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' -import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { AgentPreset } from './preset.ts' -import { classifyRowSpecifier } from './specifier.ts' - -/** What one mounted subtree publishes about itself for the audit to read. */ -interface MountedTree { - /** The rows the composition created. */ - readonly tree: EntryTree - /** - * The subtree's own fiber. Captured here rather than taken from - * `ctx.plugin()`, which hands back a thenable `Object.create(fiber)` wrapper - * that is never identical to the fiber appearing in a parent chain. - */ - readonly fiber: Fiber -} - -/** - * Subtrees captured by config identity. A subtree plugged directly (rather than - * created as a loader entry) never links itself to an `Entry`, so this is the - * only handle to the rows it created; config objects are minted per mount, so - * concurrent mounts cannot collide. - */ -const mounted = new WeakMap() - -/** - * The base URL bare specifiers resolve against, per pending mount, keyed by the - * same config object. Recorded before the subtree is plugged, because `Include` - * rewrites its own context's `baseUrl` to the composition's directory and the - * pre-mount value is the only handle on where the harness itself lives. - */ -const harnessBase = new WeakMap() - -/** - * Include subclass that publishes its tree and fiber for the audit, and never - * writes to the file it read. - */ -class PresetTree extends Include { - constructor(ctx: Context, config: Include.Config) { - super(ctx, config) - // EntryTree's constructor files every new tree under the nearest owning - // Loader entry's `subtree` slot — here the roster's own row, because the - // standing scope descends from the roster's fiber. Left in place, root - // `loader.entries()` would walk this composition as host entries (each - // preset overwriting the last), against the standing mount's contract of - // not being a Loader entry. Reclaim the slot. - const owner = this.ctx.fiber.entry - if (owner?.subtree === this) delete owner.subtree - mounted.set(config, { tree: this, fiber: ctx.fiber }) - } - - /** - * Resolve a bare specifier from the harness rather than from the preset. - * - * `EntryTree.import()` resolves against the tree's own `baseUrl`, which - * `Include` sets to the composition's directory. That is right for a - * relative specifier — a preset's own files travel with it — and wrong for - * a package name: a locally authored preset lives under the user's home, - * where Node's upward `node_modules` walk never reaches the harness's own - * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The - * mount records the host composition's base instead, which is inside the - * installed harness, and bare names resolve from there. An absolute - * filesystem path names neither base and becomes a file URL before Node's - * ESM loader receives it, which is required for drive-letter paths on - * Windows. - * - * {@link classifyRowSpecifier} makes that split, so discovery's health check - * resolves every row from the same base this import uses. - * @param name - the module specifier from the row. - * @param getOuterStack - the loader's stack composer for import diagnostics. - * @returns the imported module, or the `cordis:` builtin. - */ - override import(name: string, getOuterStack?: () => string[]): unknown { - const row = classifyRowSpecifier(name) - const base = harnessBase.get(this.config) - /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ - if (base === undefined) return super.import(row.specifier, getOuterStack) - if (row.kind === 'builtin' || row.kind === 'preset') return super.import(row.specifier, getOuterStack) - const internal = this.ctx.loader.internal - /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a - hypothetical embedder from losing the row's name in a resolution error. */ - if (internal === undefined) return super.import(row.specifier, getOuterStack) - return internal.import(row.specifier, base, {}) - } - - /** - * A preset is an input, never a persistence target. - * - * The Loader writes a tree back through this method whenever it decides the - * config changed — a plugin self-disposing is enough, and tearing an agent - * down disposes its whole subtree. Inherited, that rewrites the preset file - * with whatever the dying tree held, which in practice means truncating a - * shipped composition to `[]` the first time a session ends. Persisting a - * preset is also meaningless: nothing here is user state, and the same file - * backs every session that names it. - * - * Dropping the write drops the `loader/config-update` the inherited method - * emits with it. No consumer observes one for a preset subtree, and a - * future "edit your preset while it runs" flow needs a deliberate - * persistence path rather than this method's return. - */ - override write(): void { - } -} - -/** One preset composition currently installed under some agent. */ -export interface PresetMount { - /** The preset the subtree was composed from. */ - readonly presetId: string - /** The mounted subtree's fiber. */ - readonly fiber: Fiber - /** Loader entry tree whose active rows form this standing composition. */ - readonly tree: EntryTree - /** The standing scope key agents are parented to (undefined only in torn-down records). */ - readonly key: ScopeKey | undefined -} - -const mounts = new Set() - -/** - * 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. - * - * 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. - */ -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. - * - * The record set is module state and therefore spans every Cordis runtime in - * the process; a reader that serves one runtime passes that runtime's root - * fiber so another runtime mounting the same preset id (a second embedded - * app, a test's second harness) never answers for it. - * @param within - when present, only mounts inside this fiber's subtree. - * @returns the live mounts. - */ -export function livePresetMounts(within?: Fiber): PresetMount[] { - pruneDisposedMounts() - const all = [...mounts] - return within === undefined ? all : all.filter(mount => withinFiber(mount.fiber, within)) -} - -/** - * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. - * - * Membership is object identity. `uid` looks like a cheaper key but is a - * per-registry counter, so fibers in two different roots collide on it and a - * subtree in one runtime would be blamed for a service published in another. - * @param fiber - the fiber to locate. - * @param root - the subtree root to test membership against. - * @returns true when `fiber` belongs to `root`'s subtree. - */ -function withinFiber(fiber: Fiber, root: Fiber): boolean { - let current = fiber - while (true) { - if (current === root) return true - const parent = current.parent.fiber - if (parent === current) return false - current = parent - } -} - -/** - * Service names the mounted subtree published into the root realm. - * - * A provider without an `isolate` realm stores its implementation under the - * root's symbol for that name, which is exactly the comparison below; a - * provider inside an `isolate` realm stores under a realm-private symbol and - * is correctly absent here. - * @param ctx - any context of the runtime whose service store is inspected. - * @param mount - the mounted subtree's fiber. - * @returns the leaked service names in lexical order. - */ -export function leakedServices(ctx: Context, mount: Fiber): string[] { - const store = ctx.reflect.store - const rootIsolate = ctx.root[Context.isolate] - const leaked: string[] = [] - for (const key of Object.getOwnPropertySymbols(store)) { - const impl = store[key] - /* v8 ignore next -- cordis deletes a store slot on disposal rather than - clearing it, so an own symbol always resolves; the guard exists only - because the store's index signature is optional. */ - if (impl === undefined) continue - if (!withinFiber(impl.fiber, mount)) continue - if (rootIsolate[impl.name] === key) leaked.push(impl.name) - } - 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. - * - * A preset publishes a service behind an `isolate` realm so two sessions - * cannot collide, and an entry-local realm is invisible to everything outside - * the group — including the agent's own scope context and the host. That is - * right for the rows inside the group and wrong for one caller: a request that - * is ABOUT a session but arrives from outside it, which is every browser RPC - * the api-proxy serves. - * - * Ownership is the same relation {@link leakedServices} reads, inverted: there - * it names implementations a subtree published into the ROOT realm, here it - * names the one this subtree published anywhere. Fiber membership is object - * identity for the reason stated on {@link withinFiber}. - * - * This is READ addressing for a caller that already holds the agent. It is not - * a general host handle on a session's internals: a host row that `inject`s a - * service cannot use it, because injection resolves before any session exists - * and has no agent to key by — such a service belongs on the host plane. - * @param ctx - any context of the runtime whose service store is inspected. - * @param agent - the agent whose mounted composition to look inside. - * @param name - the service name as the preset's rows resolve it. - * @returns the agent's instance, or undefined when its preset mounts none. - */ -export function serviceForAgent( - ctx: Context, - agent: { ctx: Context }, - name: K, -): Context[K] | undefined { - const mount = standingMountFor(agent.ctx) - if (mount === undefined) return undefined - const store = ctx.reflect.store - for (const key of Object.getOwnPropertySymbols(store)) { - const impl = store[key] - /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ - if (impl === undefined) continue - if (impl.name !== name) continue - if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K] - } - return undefined -} - -/** - * Rows that did not reach a usable state, each rendered as one diagnostic line. - * - * Wait for the subtree, then report import failures, activation failures, and - * rows waiting for services the composition does not supply. - * @param tree - the mounted subtree. - * @returns one line per unusable row, empty when every enabled row is usable. - */ -export async function inactiveRows(tree: EntryTree): Promise { - await tree.await() - const lines: string[] = [] - for (const entry of tree.entries()) { - if (entry.disabled) continue - const fiber = entry.fiber - if (fiber === undefined) { - lines.push(`${entry.options.id} (${entry.options.name}): never started`) - continue - } - try { - await fiber.await() - } catch (error) { - const detail = mountDetail(error) - lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`) - continue - } - const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) - if (missing.length > 0) { - lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) - } - } - return lines -} - -/** - * The causes of `error` whose detail its own message does not already carry. - * - * Aggregate errors carry separate member messages. A wrapper can preserve the - * aggregate as its cause without including those messages in its own text. - * @param error - the failure to read branches from. - * @returns the branches to render beneath `error.message`, possibly empty. - */ -function detailBranches(error: Error): readonly unknown[] { - if (error instanceof AggregateError) return error.errors - return error.cause instanceof AggregateError ? error.cause.errors : [] -} - -/** - * The reportable text of a mount failure. - * - * A plugin may reject with an aggregate or wrap one as its cause. Include its - * member messages beneath the row diagnostic so each failure is visible. - * @param error - the value the mount rejected with. - * @returns a single-line-per-cause description. - */ -function mountDetail(error: unknown): string { - if (!(error instanceof Error)) return String(error) - const branches = detailBranches(error) - if (branches.length === 0) return error.message - return [ - error.message, - ...branches.map(branch => `- ${mountDetail(branch).replaceAll('\n', '\n ')}`), - ].join('\n') -} - -/** - * Mount `preset` under `agentCtx` and return only once every row is usable. - * - * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and - * the caller receives no disposer. A rejection leaves nothing mounted. - * @param agentCtx - the agent's scope context, from the agent factory's `setup`. - * @param preset - the resolved preset to compose the agent from. - * @throws when `agentCtx` carries no scope, a row is unusable, or a row - * published a service into the root realm. - */ -export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise { - const scope = scopeOf(agentCtx) - if (scope === undefined) { - throw new Error( - `agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; ` - + 'its registrations would apply to every agent in the process', - ) - } - const config: Include.Config = { path: pathToFileURL(preset.path).href } - // Captured before the subtree exists: the standing scope context still - // carries the host composition's base, which is inside the installed - // harness and is therefore where a row's package name has to resolve from. - /* v8 ignore next -- the Loader sets `baseUrl` on the root before any scoped context derives from it */ - if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl) - // Before the record this mount is about to add: standing mounts are one per - // preset and live until whole-tree teardown, so pruning here only sweeps - // records of torn-down runtimes (tests; an HMR reload of the roster). - pruneDisposedMounts() - const handle = agentCtx.plugin(PresetTree, config) - try { - await handle.await() - const subtree = mounted.get(config) - /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */ - if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree') - const { tree, fiber } = subtree - const unusable = await inactiveRows(tree) - if (unusable.length > 0) { - throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`) - } - const leaked = leakedServices(agentCtx, fiber) - if (leaked.length > 0) { - throw new Error( - `row(s) published process-global service(s) [${leaked.join(', ')}]; ` - + 'a preset service must sit behind an `isolate` realm or move to the host composition', - ) - } - mounts.add({ presetId: preset.id, fiber, tree, key: scopeOf(agentCtx) }) - } catch (error) { - try { - await handle.dispose() - /* v8 ignore next 5 -- teardown of a subtree nothing else references has no - observed failure mode; the guard exists so a teardown error cannot - replace the mount diagnostic the caller needs. */ - } catch { - // Swallows only this subtree's teardown failure. The mount error below is - // the actionable one, and the discarded fiber is unreachable either way. - } - const reason = `${mountDetail(error)} (${preset.path})` - throw new RemoteError( - 'agent-preset/invalid', - `agent-presets: preset "${preset.id}" failed to mount: ${reason}`, - { agentPreset: preset.id, reason }, - { cause: error }, - ) - } -} diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts deleted file mode 100644 index 5ffa58aee4..0000000000 --- a/packages/preset/agent-presets/src/preset.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Agent-preset vocabulary shared by discovery, mounting, and consumers. */ - -/** - * 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' - -/** - * Ids a preset directory may use. - * - * The id becomes a path segment, so this is a containment boundary rather than - * a style rule: `..`, a separator, or an absolute-looking name would place the - * composition outside the root the deployment authorised. Discovery shares it: - * a directory whose name no copy could ever claim is not a preset slot. - */ -export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ - -/** One preset directory that carries a mountable agent composition. */ -export interface AgentPreset { - /** Stable identifier; the preset directory's name. */ - readonly id: string - /** Trust recorded from the root this preset was discovered under. */ - readonly trust: PresetTrust - /** Absolute path of the preset's agent composition file. */ - readonly path: string - /** Display name from the preset's own metadata; absent falls back to {@link id}. */ - readonly name?: string - /** One sentence on what this preset is for, when it published one. */ - readonly description?: string - /** Declared position within its group; absent sorts after those that declare one. */ - readonly order?: number - /** - * Why this preset cannot compose a session, absent when it can. A broken - * preset stays on the roster — hiding it would leave its directory blocking - * the id with nothing to see or delete — but every mounting path refuses it - * up front with this reason instead of failing deep inside the loader. - */ - readonly broken?: string -} - -/** One directory scanned for preset subdirectories. */ -export interface PresetRoot { - /** Directory holding one subdirectory per preset; a leading `~` expands. */ - path: string - /** Trust recorded on every preset discovered under this root. */ - trust: PresetTrust -} - -/** Plugin config: which preset is the default, and where presets live. */ -export interface Config { - /** 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[] - /** - * Prepend this package's bundled shipped presets as a `system` root, before - * every configured root, so the shipped set always mounts and wins a - * duplicate id. The default survives a whole-`config` patch replacement; - * only an explicit `false` — a deployment supplying purely its own presets, - * or an embedder using the roster as bare machinery — drops the set. - */ - includeShippedRoot: boolean - /** - * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every - * configured root. False mounts a roster without the derived writable root. - */ - includeUserRoot: boolean -} diff --git a/packages/preset/agent-presets/src/specifier.ts b/packages/preset/agent-presets/src/specifier.ts deleted file mode 100644 index c14233517e..0000000000 --- a/packages/preset/agent-presets/src/specifier.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * How one composition row's `name` reaches a module. - * - * A preset composition is read by `Include`, which rewrites its context's - * `baseUrl` to the composition's own directory. That is right for a row - * naming a file the preset ships and wrong for a row naming a package: a - * locally authored preset lives under the user's home, where Node's upward - * `node_modules` walk never reaches the harness's own dependencies. Both the - * mount's import override and discovery's health check therefore have to - * classify a row's name before they can act on it, and they must classify it - * the same way — a row discovery resolves from one base and the mount imports - * from another would be reported healthy and then fail to load. - * @module @deepseek-ai/dsh-agent-presets/specifier - */ - -import { isAbsolute } from 'node:path' -import { pathToFileURL } from 'node:url' - -/** One composition row's module specifier, classified by where it resolves. */ -export type RowSpecifier = - /** A `cordis:` builtin the Loader supplies; nothing is resolved. */ - | { readonly kind: 'builtin'; readonly specifier: string } - /** A path relative to the preset's own directory; the preset ships the file. */ - | { readonly kind: 'preset'; readonly specifier: string } - /** An absolute path or `file:` URL; it names one file and no base. */ - | { readonly kind: 'file'; readonly specifier: string } - /** A package name resolved from the installed harness. */ - | { readonly kind: 'package'; readonly specifier: string } - -/** - * Classify one row's `name`. - * - * An absolute filesystem path becomes a file URL here rather than at each - * call site, because Node's ESM resolver rejects a bare drive-letter path on - * Windows. A `file:` URL is already one and joins it: the Loader accepts both - * spellings for the same thing, and treating the URL as a package name would - * hand it to a resolver that only normalizes it, reporting a file that is not - * there as present. The `specifier` a caller receives is always the string to - * hand a resolver; only `kind` decides which base it goes with. - * @param name - the module specifier exactly as the row wrote it. - * @returns the classification, carrying the specifier to resolve. - */ -export function classifyRowSpecifier(name: string): RowSpecifier { - if (name.startsWith('cordis:')) return { kind: 'builtin', specifier: name } - if (name.startsWith('.')) return { kind: 'preset', specifier: name } - if (name.startsWith('file:')) return { kind: 'file', specifier: name } - if (isAbsolute(name)) return { kind: 'file', specifier: pathToFileURL(name).href } - return { kind: 'package', specifier: name } -} diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts deleted file mode 100644 index 0723fe93da..0000000000 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Authoring a preset copies an existing one's directory into the deployment's - * `user` root — copy is the only authoring write, so no caller ever supplies - * composition text. The id is a directory name, so its pattern is a - * containment boundary rather than a style rule; the shipped `.system` set - * stays read-only. - */ - -import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import AgentPresets, { - COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config, -} from '@deepseek-ai/dsh-agent-presets' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' - -/** Every temp root created by this file, removed after each test. */ -const roots: string[] = [] -afterEach(async () => { - for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -let ctx: Context -let userRoot: string - -/** Mount the required projection seam before the roster service. */ -async function mountAgentPresets(context: Context, config: Config): Promise { - await context.plugin(SessionProjectionRegistry) - await context.plugin(AgentPresets, config) -} - -/** Hand-craft a preset directory (tests cannot author text through the service). */ -async function seedPreset( - root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record } = {}, -): Promise { - await mkdir(join(root, id), { recursive: true }) - await writeFile(join(root, id, COMPOSITION_FILE), options.composition ?? VALID) - if (options.metadata !== undefined) { - await writeFile(join(root, id, METADATA_FILE), options.metadata) - } - for (const [name, content] of Object.entries(options.extras ?? {})) { - await mkdir(dirname(join(root, id, name)), { recursive: true }) - await writeFile(join(root, id, name), content) - } -} - -beforeEach(async () => { - userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')) - roots.push(userRoot) - ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await mountAgentPresets(ctx, { - default: 'standard', - roots: [ - { path: join(FIXTURES, 'system'), trust: 'system' as const }, - { path: userRoot, trust: 'user' as const }, - ], - // Every roster in this file pins its own roots: the package's shipped - // presets would shadow the fixture ids, and the derived harness-home root - // would add the developer's real presets to what these assertions count — - // and `copy` would write into it. - includeShippedRoot: false, - includeUserRoot: false, - }) -}) - -describe('copying a preset', () => { - it('copies a shipped preset into the user root and lists it', async () => { - await ctx.agentPresets.copy('standard', 'mine') - - expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')) - .toBe(await ctx.agentPresets.read('standard')) - const listed = await ctx.agentPresets.list() - expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') - }) - - it('copies the whole directory and tightens POSIX modes', async () => { - await seedPreset(userRoot, 'source', { - extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, - }) - if (process.platform !== 'win32') { - await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) - } - - await ctx.agentPresets.copy('source', 'mine') - - expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') - // Windows mode bits are synthetic and cannot represent the inherited DACL. - if (process.platform !== 'win32') { - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) - expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) - } - }) - - it('keeps the source description but never its name or order', async () => { - await seedPreset(userRoot, 'source', { metadata: 'name: 源模式\ndescription: 只做检索。\norder: 1\n' }) - - await ctx.agentPresets.copy('source', 'mine') - - // Two rows presenting identically is how a roster stops being a chooser, - // and the shipped set's declared order is not the copy's to claim. - const metadata = await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8') - expect(metadata).toContain('description: 只做检索。') - expect(metadata).not.toContain('name:') - expect(metadata).not.toContain('order:') - expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')) - .toMatchObject({ description: '只做检索。' }) - }) - - it('stores the display name the author supplied', async () => { - await ctx.agentPresets.copy('standard', 'mine', '我的模式') - - expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')).toContain('name: 我的模式') - expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')) - .toMatchObject({ name: '我的模式' }) - }) - - it('publishes no metadata file when there is nothing to publish', async () => { - await seedPreset(userRoot, 'source') - - await ctx.agentPresets.copy('source', 'mine') - - // An empty metadata document would read as an intentional blank name; - // absence is what "this preset publishes no display text" looks like. - expect(existsSync(join(userRoot, 'mine', METADATA_FILE))).toBe(false) - expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined() - }) - - it('refuses an id that could escape the preset root', async () => { - for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) { - await expect(ctx.agentPresets.copy('standard', id)).rejects.toThrow(/must match/) - } - // Nothing was created for any of them. - expect(existsSync(join(userRoot, 'escape'))).toBe(false) - }) - - it('refuses an id the roster already supplies, shipped ones included', async () => { - await ctx.agentPresets.copy('standard', 'mine') - - await expect(ctx.agentPresets.copy('standard', 'mine')).rejects.toThrow(/already exists/) - // A user directory named like a shipped preset would be shadowed by it. - await expect(ctx.agentPresets.copy('standard', 'minimal')).rejects.toThrow(/already exists/) - }) - - it('refuses a directory that occupies the name without being a preset', async () => { - await mkdir(join(userRoot, 'occupied'), { recursive: true }) - await writeFile(join(userRoot, 'occupied', 'README.txt'), 'nope\n') - - // Discovery does not list it (no composition file), so only the disk - // check can refuse it with a readable error instead of a filesystem code. - await expect(ctx.agentPresets.copy('standard', 'occupied')).rejects.toThrow(/already exists/) - expect(await readFile(join(userRoot, 'occupied', 'README.txt'), 'utf8')).toBe('nope\n') - }) - - it('reports an unknown source rather than creating anything', async () => { - await expect(ctx.agentPresets.copy('never-existed', 'mine')).rejects.toThrow(/not found/) - expect(existsSync(join(userRoot, 'mine'))).toBe(false) - }) - - it('leaves nothing behind when the copy itself fails', async () => { - const source = { - id: 'gone', - trust: 'user' as const, - path: join(userRoot, 'gone', COMPOSITION_FILE), - } - - // The source vanished between resolve and copy: the half-made target is - // rolled back rather than left invisible to discovery. - await expect(copyComposition( - [{ path: userRoot, trust: 'user' as const }], source, 'mine', - )).rejects.toThrow() - expect(existsSync(join(userRoot, 'mine'))).toBe(false) - }) -}) - -describe('deleting a preset', () => { - it('removes a locally authored one', async () => { - await ctx.agentPresets.copy('standard', 'mine') - - await ctx.agentPresets.remove('mine') - - expect(existsSync(join(userRoot, 'mine'))).toBe(false) - expect((await ctx.agentPresets.list()).some(preset => preset.id === 'mine')).toBe(false) - }) - - it('refuses to delete a shipped one', async () => { - await expect(ctx.agentPresets.remove('standard')) - .rejects.toThrow(/ships with the deployment/) - }) - - it('reports an unknown id rather than silently succeeding', async () => { - await expect(ctx.agentPresets.remove('never-existed')).rejects.toThrow(/not found/) - }) -}) - -describe('a deployment with more than one user root', () => { - it('refuses to delete a preset the writable root does not own', async () => { - const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-')) - roots.push(second) - await seedPreset(second, 'elsewhere') - const layered = new Context() - layered.baseUrl = pathToFileURL(FIXTURES).href + '/' - await layered.plugin(Loader) - layered.loader.builtins.include = Include - await mountAgentPresets(layered, { - default: 'standard', - roots: [ - { path: userRoot, trust: 'user' as const }, - { path: second, trust: 'user' as const }, - ], - includeShippedRoot: false, - includeUserRoot: false, - }) - - // Writes go to the first user root, so a preset discovered from a later - // one is `user` trust yet outside what deletion is allowed to touch — - // `rm -r` on a directory this root does not own is the failure to avoid. - await expect(layered.agentPresets.remove('elsewhere')) - .rejects.toThrow(/does not live under the writable preset root/) - expect(existsSync(join(second, 'elsewhere'))).toBe(true) - }) -}) - -describe('a deployment with no writable root', () => { - it('says authoring is unavailable rather than guessing a directory', async () => { - const readOnly = new Context() - readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/' - await readOnly.plugin(Loader) - readOnly.loader.builtins.include = Include - await mountAgentPresets(readOnly, { - default: 'standard', - roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - expect(readOnly.agentPresets.authorable).toBe(false) - await expect(readOnly.agentPresets.copy('standard', 'mine')) - .rejects.toThrow(/no user-writable preset root/) - }) -}) - -describe('a user root that does not exist yet', () => { - it('is created by the first copy', async () => { - const absentRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')) - roots.push(absentRoot) - const absent = join(absentRoot, 'nested', 'preset') - const fresh = new Context() - fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' - await fresh.plugin(Loader) - fresh.loader.builtins.include = Include - await mountAgentPresets(fresh, { - default: 'standard', - roots: [ - { path: join(FIXTURES, 'system'), trust: 'system' as const }, - { path: absent, trust: 'user' as const }, - ], - includeShippedRoot: false, - includeUserRoot: false, - }) - - await fresh.agentPresets.copy('standard', 'mine') - - expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')) - .toBe(await fresh.agentPresets.read('standard')) - }) -}) - -describe('display metadata beside a composition', () => { - it('keeps a composition mountable when its metadata is unreadable', async () => { - await ctx.agentPresets.copy('standard', 'mine') - await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n') - - // Presentation is not capability: discovery still yields the preset. - const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine') - expect(listed?.name).toBeUndefined() - expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' }) - }) -}) - -describe('the on-disk occupancy backstop', () => { - it('refuses a directory the roster cannot see', async () => { - // The service's roster check sees every id-shaped directory now, so this - // is the race backstop: a directory appearing between the roster read and - // the copy still gets the readable refusal, not a filesystem error code. - await mkdir(join(userRoot, 'raced'), { recursive: true }) - const source = await ctx.agentPresets.resolve('standard') - - await expect(copyComposition( - [{ path: userRoot, trust: 'user' as const }], source, 'raced', - )).rejects.toThrow(/already exists/) - }) -}) - -describe('a ghost directory under the user root', () => { - it('lists broken, blocks its id, and clears through remove', async () => { - // The classic hand-edit: the composition file was deleted, the directory - // stayed. It must not vanish from the roster — its id is still taken, so - // there has to be something to see and delete. - await mkdir(join(userRoot, 'ghost'), { recursive: true }) - await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n') - - const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost') - expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/) - await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/) - - // remove is the way out the roster row offers; the id is claimable again. - await ctx.agentPresets.remove('ghost') - expect(existsSync(join(userRoot, 'ghost'))).toBe(false) - await ctx.agentPresets.copy('standard', 'ghost') - expect((await ctx.agentPresets.list()).map(preset => preset.id)).toContain('ghost') - // A copy carries the whole preset directory, so a preset's own files - // travel with it. This fixture's rows reach OUTSIDE that directory, which - // no real preset does and which no copy can carry — so the claim here is - // the reclaimed id and the restored composition, not the rows' targets. - expect(existsSync(join(userRoot, 'ghost', COMPOSITION_FILE))).toBe(true) - }) -}) diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts deleted file mode 100644 index adce715d39..0000000000 --- a/packages/preset/agent-presets/tests/composition-inventory.spec.ts +++ /dev/null @@ -1,421 +0,0 @@ -/** - * Structured composition reads: the flattened plugin rows a preset names, - * answered from the composition file while no session has mounted the preset - * and from the standing mount once one has, with a composition that cannot be - * read reported broken by reason instead of dropped. - */ - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context, FiberState } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { afterEach, describe, expect, it, vi } from 'vitest' -import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' -import type { Config } from '@deepseek-ai/dsh-agent-presets' -import { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import { fileComposition, mountedCompositionRows } from '../src/composition-inventory.ts' -import { livePresetMounts } from '../src/mount.ts' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const SYSTEM_ROOT = { path: join(FIXTURES, 'system'), trust: 'system' as const } -// A row naming a package installed beside the harness, the way authored rows do. -const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' - -const contexts: Context[] = [] - -/** Every temp root created by this file, removed after its contexts settle. */ -const roots: string[] = [] - -/** A Loader-context evaluator over an empty scope, enough for literal gates. */ -const evaluateExpression = (expression: string): unknown => evaluate({}, expression) -/** An evaluator that refuses every expression, leaving rows conditional. */ -const refuseExpression = (): never => { throw new Error('no loader context') } - -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) - for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -async function harness(roster: Config): Promise { - const ctx = new Context() - contexts.push(ctx) - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, roster) - return ctx -} - -describe('fileComposition', () => { - it('flattens groups and keeps refused expressions conditional', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) - roots.push(dir) - const path = join(dir, COMPOSITION_FILE) - await writeFile(path, [ - '- id: alpha', - ' name: pkg-alpha', - '- name: pkg-anonymous', - '- id: off', - ' name: pkg-off', - ' disabled: true', - '- id: cond', - ' name: pkg-cond', - ' disabled: !!js process.platform === \'win32\'', - '- id: grp', - ' name: cordis:group', - ' group: true', - ' config:', - ' - id: child', - ' name: pkg-child', - ' - id: child-off', - ' name: pkg-child-off', - ' disabled: true', - '- id: grp-off', - ' name: cordis:group', - ' group: true', - ' disabled: true', - ' config:', - ' - id: buried', - ' name: pkg-buried', - '- id: grp-cond', - ' name: cordis:group', - ' group: true', - ' disabled: !!js 1', - ' config:', - ' - id: maybe', - ' name: pkg-maybe', - ' - id: certainly-off', - ' name: pkg-certainly-off', - ' disabled: true', - ].join('\n')) - - expect(await fileComposition(path, refuseExpression)).toEqual({ - rows: [ - { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true }, - { entryId: null, moduleName: 'pkg-anonymous', enabled: true }, - { entryId: 'off', moduleName: 'pkg-off', enabled: false }, - { - entryId: 'cond', - moduleName: 'pkg-cond', - enabled: 'conditional', - condition: 'process.platform === \'win32\'', - }, - { entryId: 'child', moduleName: 'pkg-child', enabled: true }, - { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false }, - { entryId: 'buried', moduleName: 'pkg-buried', enabled: false }, - { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional' }, - { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false }, - ], - }) - }) - - it('evaluates decidable gates the way a mount would', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) - roots.push(dir) - const path = join(dir, COMPOSITION_FILE) - await writeFile(path, [ - '- id: off', - ' name: pkg-off', - ' disabled: !!js 1 === 1', - '- id: on', - ' name: pkg-on', - ' disabled: !!js 1 === 2', - ].join('\n')) - - expect(await fileComposition(path, evaluateExpression)).toEqual({ - rows: [ - { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1' }, - { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2' }, - ], - }) - }) - - it('answers broken for a file that stopped reading as a composition', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) - roots.push(dir) - - const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression) - expect(missing).toHaveProperty('broken') - - const unparsable = join(dir, 'unparsable.yml') - await writeFile(unparsable, 'foo: [') - const yaml = await fileComposition(unparsable, refuseExpression) - expect('broken' in yaml && yaml.broken.length > 0).toBe(true) - - const rowless = join(dir, 'rowless.yml') - await writeFile(rowless, 'foo: bar\n') - expect(await fileComposition(rowless, refuseExpression)).toEqual({ - broken: 'the composition must be a top-level list of plugin rows', - }) - }) -}) - -describe('mountedCompositionRows', () => { - it('reads evaluated enablement and root-fiber states, skipping group rows', async () => { - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(Loader) - ctx.loader.builtins.active = () => {} - const activeId = await ctx.loader.create({ name: 'cordis:active' }) - const disabledId = await ctx.loader.create({ name: 'cordis:active', disabled: true }) - const evaluatedId = await ctx.loader.create({ - name: 'cordis:active', - // The YAML `!!js` tag deserializes to exactly this object; EntryOptions - // types the field by its literal form only. - disabled: { __jsExpr: 'false' } as unknown as boolean, - }) - await ctx.loader.create({ name: 'cordis:active', group: true }) - - // Keyed rather than ordered: rows follow `loader.entries()`, whose plain - // object store reorders an auto-generated all-digit id ahead of its - // siblings by integer-key semantics — ordering is the store's contract, - // not this projection's. - const rows = mountedCompositionRows(ctx.loader) - const byId = new Map(rows.map(row => [row.entryId, row])) - expect(rows).toHaveLength(3) - expect(byId.get(activeId)).toEqual( - { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE }) - expect(byId.get(disabledId)).toEqual( - { entryId: disabledId, moduleName: 'cordis:active', enabled: false }) - expect(byId.get(evaluatedId)).toEqual({ - entryId: evaluatedId, - moduleName: 'cordis:active', - enabled: true, - condition: 'false', - fiberState: FiberState.ACTIVE, - }) - }) -}) - -describe('AgentPresets.compositionInventory', () => { - it('reads unmounted presets from their files, marking the default and metadata', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) - roots.push(userRoot) - await mkdir(join(userRoot, 'documented')) - await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [ - VALID.trimEnd(), - '- id: gated', - ' name: \'@deepseek-ai/dsh-system-prompt\'', - ' disabled: !!js 1 === 1', - '- id: undecidable', - ' name: \'@deepseek-ai/dsh-system-prompt\'', - ' disabled: !!js nothing.here', - ].join('\n')) - await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\n') - const ctx = await harness({ - default: 'minimal', - roots: [SYSTEM_ROOT, { path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - expect(await ctx.agentPresets.compositionInventory()).toEqual([ - { - id: 'minimal', - trust: 'system', - isDefault: true, - rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true }], - }, - { - id: 'standard', - trust: 'system', - isDefault: false, - rows: [ - { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true }, - { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, - ], - }, - { - id: 'documented', - trust: 'user', - name: '我的模式', - isDefault: false, - rows: [ - { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true }, - // The platform-gate shape: the service evaluates it with the - // Loader's own scope, so the file answer matches a mount's. - { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1' }, - // An expression the evaluator refuses stays a mount's decision. - { - entryId: 'undecidable', - moduleName: '@deepseek-ai/dsh-system-prompt', - enabled: 'conditional', - condition: 'nothing.here', - }, - ], - }, - ]) - // Reading is never mounting: every unmounted preset above was answered - // from its file, so listing plugins cannot activate a preset early. - expect(livePresetMounts()).toEqual([]) - }) - - it('reads a mounted preset from its standing composition', async () => { - const ctx = await harness({ - default: 'standard', - roots: [SYSTEM_ROOT], - includeShippedRoot: false, - includeUserRoot: false, - }) - await ctx.agents.create({ - sessionId: SessionId('composition-inventory'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - - const standard = (await ctx.agentPresets.compositionInventory()) - .find(composition => composition.id === 'standard') - expect(standard?.rows).toEqual([ - { - entryId: 'alpha', - moduleName: '../../plugins/contribute.js', - enabled: true, - fiberState: FiberState.ACTIVE, - }, - { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, - ]) - }) - - it('prefers the standing mount over a file that broke after mounting', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-composition-volatile-')) - roots.push(root) - await mkdir(join(root, 'volatile')) - const plugin = join(FIXTURES, 'plugins', 'contribute.js') - await writeFile( - join(root, 'volatile', COMPOSITION_FILE), - `- id: only\n name: ${plugin}\n config:\n tool: volatile\n`, - ) - const ctx = await harness({ - default: 'volatile', - roots: [{ path: root, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - await ctx.agents.create({ - sessionId: SessionId('broken-after-mount'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'volatile'), - }) - // The file breaks AFTER a session composed it; the standing mount is what - // that session still runs, so the inventory must keep answering from it. - await writeFile(join(root, 'volatile', COMPOSITION_FILE), 'foo: [') - - const [volatile] = await ctx.agentPresets.compositionInventory() - expect(volatile).toMatchObject({ id: 'volatile', trust: 'user', isDefault: true }) - expect(volatile?.broken).toBeUndefined() - expect(volatile?.rows).toEqual([ - { entryId: 'only', moduleName: plugin, enabled: true, fiberState: FiberState.ACTIVE }, - ]) - }) - - it('keeps another runtime\'s standing mount out of this runtime\'s inventory', async () => { - const roster: Config = { - default: 'standard', - roots: [SYSTEM_ROOT], - includeShippedRoot: false, - includeUserRoot: false, - } - const mountedRuntime = await harness(roster) - const idleRuntime = await harness(roster) - await mountedRuntime.agents.create({ - sessionId: SessionId('cross-runtime'), - setup: async (agentCtx: Context) => void await mountedRuntime.agentPresets.mount(agentCtx, 'standard'), - }) - expect(livePresetMounts(mountedRuntime.fiber).filter(mount => mount.presetId === 'standard')).toHaveLength(1) - expect(livePresetMounts(idleRuntime.fiber).filter(mount => mount.presetId === 'standard')).toHaveLength(0) - - // The other runtime's mount must not answer here: these rows come from - // the file, so none carries a root-fiber state. - const idle = (await idleRuntime.agentPresets.compositionInventory()) - .find(composition => composition.id === 'standard') - expect(idle?.rows.length).toBeGreaterThan(0) - expect(idle?.rows.every(row => row.fiberState === undefined)).toBe(true) - const live = (await mountedRuntime.agentPresets.compositionInventory()) - .find(composition => composition.id === 'standard') - expect(live?.rows.some(row => row.fiberState !== undefined)).toBe(true) - }) - - it('keeps a broken preset on the inventory with its discovery reason', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) - roots.push(userRoot) - await mkdir(join(userRoot, 'damaged')) - const ctx = await harness({ - default: 'minimal', - roots: [SYSTEM_ROOT, { path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - const damaged = (await ctx.agentPresets.compositionInventory()) - .find(composition => composition.id === 'damaged') - expect(damaged?.rows).toEqual([]) - expect(damaged?.broken).toContain('is missing') - }) - - it('keeps a standing composition out of the root Loader entries', async () => { - const ctx = new Context() - contexts.push(ctx) - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - ctx.loader.builtins['agent-presets'] = AgentPresets - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - // The roster itself loads as a Loader entry, the way profiles mount it: - // the standing scope then descends from a fiber that OWNS an entry, which - // is exactly the shape that made EntryTree file the mount under it. - await ctx.loader.create({ - name: 'cordis:agent-presets', - config: { default: 'standard', roots: [SYSTEM_ROOT], includeShippedRoot: false, includeUserRoot: false }, - }) - const before = [...ctx.loader.entries()].map(entry => entry.id) - - await ctx.agents.create({ - sessionId: SessionId('loader-entry-guard'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - - // The agent joined a standing composition without the composition - // becoming host Loader entries. - expect([...ctx.loader.entries()].map(entry => entry.id)).toEqual(before) - }) - - it('reports a composition that raced discovery as broken instead of dropping it', async () => { - const ctx = await harness({ - default: 'minimal', - roots: [SYSTEM_ROOT], - includeShippedRoot: false, - includeUserRoot: false, - }) - // Discovery judged the preset healthy, then the file vanished before the - // row read: the inventory keeps the preset and carries the raced reason. - vi.spyOn(ctx.agentPresets, 'list').mockResolvedValue([ - { id: 'ghost', trust: 'user', path: join(FIXTURES, 'ghost', COMPOSITION_FILE) }, - ]) - - const [ghost] = await ctx.agentPresets.compositionInventory() - expect(ghost?.rows).toEqual([]) - expect(ghost?.broken).toBeDefined() - }) -}) diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts deleted file mode 100644 index 3c22525bdf..0000000000 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' - -const fsHarness = vi.hoisted(() => ({ - nextReadError: undefined as NodeJS.ErrnoException | undefined, -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readFile: (async (path: unknown, ...rest: never[]) => { - const error = fsHarness.nextReadError - if (error !== undefined) { - fsHarness.nextReadError = undefined - throw error - } - return (actual.readFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) - }) as typeof actual.readFile, - } -}) - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -// Standing in for the installed harness: a row's package name resolves from -// here, and this directory's upward `node_modules` walk reaches the workspace. -const HARNESS = new URL('.', import.meta.url).href -const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } -const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } - -/** Every temp root created by this file, removed after each test. */ -const roots: string[] = [] -afterEach(async () => { - for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -beforeEach(() => { - fsHarness.nextReadError = undefined -}) - -describe('display order', () => { - it('puts declared order first, then everything else by id', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) - roots.push(root) - for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) { - await mkdir(join(root, id), { recursive: true }) - await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') - await writeFile(join(root, id, 'preset.yml'), `order: ${String(order)}\n`) - } - for (const id of ['bravo', 'yankee']) { - await mkdir(join(root, id), { recursive: true }) - await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') - } - - const found = await scanRoot({ path: root, trust: 'system' }, HARNESS) - - // The shipped set reads by capability; presets that declare nothing stay - // alphabetical behind them rather than interleaving unpredictably. - expect(found.map(preset => preset.id)).toEqual(['zulu', 'alpha', 'bravo', 'yankee']) - }) - - it('breaks a tie between equal declared orders by id', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-')) - roots.push(root) - for (const id of ['yankee', 'alpha']) { - await mkdir(join(root, id), { recursive: true }) - await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') - await writeFile(join(root, id, 'preset.yml'), 'order: 1\n') - } - - const found = await scanRoot({ path: root, trust: 'system' }, HARNESS) - - // Two presets claiming the same slot must still list in a stable order: - // a directory-scan order would reshuffle the picker between reads. - expect(found.map(preset => preset.id)).toEqual(['alpha', 'yankee']) - }) -}) - -describe('preset discovery', () => { - it('reports one preset per directory holding a composition, ordered by id', async () => { - const found = await scanRoot(SYSTEM, HARNESS) - - expect(found.map(preset => preset.id)).toEqual(['minimal', 'standard']) - expect(found[0]).toEqual({ - id: 'minimal', - trust: 'system', - path: join(SYSTEM.path, 'minimal', COMPOSITION_FILE), - }) - }) - - it('reports a directory with no composition as a broken preset slot', async () => { - const found = await scanRoot(USER, HARNESS) - - // The directory still occupies its id — a copy to that name is refused — - // so hiding it would leave nothing to see or delete. It surfaces broken. - const ghost = found.find(preset => preset.id === 'not-a-preset') - expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/) - }) - - it('skips a directory whose name no preset id could ever claim', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-')) - roots.push(root) - await mkdir(join(root, '.hidden')) - await mkdir(join(root, 'Has_Caps')) - await mkdir(join(root, 'usable')) - await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n') - - const found = await scanRoot({ path: root, trust: 'user' }, HARNESS) - - // `.hidden` and `Has_Caps` cannot collide with any copy target, so - // reporting tool residue as broken presets would only train users to - // ignore the marker. - expect(found.map(preset => preset.id)).toEqual(['usable']) - }) - - it('records the root trust on every preset it discovers', async () => { - const found = await scanRoot(USER, HARNESS) - - expect(found.every(preset => preset.trust === 'user')).toBe(true) - }) - - it('lets the earlier root win a duplicate id', async () => { - const found = await discoverPresets([SYSTEM, USER], HARNESS) - - const standard = found.filter(preset => preset.id === 'standard') - expect(standard).toHaveLength(1) - expect(standard[0]?.trust).toBe('system') - }) - - it('treats an absent root as supplying no presets', async () => { - const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' }, HARNESS) - - expect(found).toEqual([]) - }) - - it('ignores a plain file sitting beside the preset directories', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) - roots.push(root) - await writeFile(join(root, 'stray.yml'), '- id: x\n') - await mkdir(join(root, 'real')) - await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n') - - const found = await scanRoot({ path: root, trust: 'user' }, HARNESS) - - expect(found.map(preset => preset.id)).toEqual(['real']) - }) - - it('reports a root it cannot read rather than treating it as empty', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) - roots.push(root) - const notADirectory = join(root, 'file-as-root') - await writeFile(notADirectory, 'not a directory\n') - - await expect(scanRoot({ path: notADirectory, trust: 'user' }, HARNESS)) - .rejects.toThrow(/cannot read preset root/) - }) - - it('expands a leading tilde in a root path', async () => { - // `~` alone resolves to the home directory, which exists but holds no - // preset directories; the point is that it did not throw on a literal `~`. - const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' }, HARNESS) - - expect(found).toEqual([]) - }) -}) - -describe('composition health', () => { - /** - * One directory under a fresh root holding `composition`, scanned. - * - * Rows that exist only to carry a shape name `js-yaml`, a package the - * harness base really resolves: health resolves every enabled row's module, - * so an invented name would answer the wrong check. - * @param composition - the composition file's contents. - * @returns the reported reason, or undefined when the composition is healthy. - */ - async function scanned(composition: string): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-')) - roots.push(root) - await mkdir(join(root, 'probe')) - await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) - const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) - return preset?.broken - } - - it('reports unparsable YAML with the parser\'s reason', async () => { - expect(await scanned('- id: x\n name: [unclosed\n')).toMatch(/not valid YAML/) - }) - - it('reports a composition that is not a list of rows', async () => { - expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/) - }) - - it('reports the first row that names no plugin, by position', async () => { - expect(await scanned('- id: ok\n name: js-yaml\n- id: broken\n')) - .toMatch(/row 2 names no plugin/) - }) - - it('reports a row that is not a map at all', async () => { - expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/) - }) - - it('descends into a group\'s own row list', async () => { - const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n' - expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/) - }) - - it('reports a group whose config is not a list', async () => { - const composition = '- id: grp\n name: cordis:group\n group: true\n config: not-a-list\n' - expect(await scanned(composition)).toMatch(/group row 1 must hold a list/) - }) - - it('accepts a group whose own list is healthy', async () => { - const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: js-yaml\n' - expect(await scanned(composition)).toBeUndefined() - }) - - it('reports a composition that stats but cannot be read', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-')) - roots.push(root) - await mkdir(join(root, 'sealed')) - const path = join(root, 'sealed', COMPOSITION_FILE) - await writeFile(path, '[]\n') - fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) - - const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) - - expect(fsHarness.nextReadError).toBeUndefined() - expect(preset?.broken).toMatch(/cannot be read/) - }) - - it('accepts the loader dialect, !!js scalars included', async () => { - // Health must never call a composition broken that the loader accepts: - // `!!js` is the loader's own extension, so it parses here too. - const composition = '- id: x\n name: js-yaml\n config:\n value: !!js "1 + 1"\n' - expect(await scanned(composition)).toBeUndefined() - }) - - it('accepts an empty list', async () => { - expect(await scanned('[]\n')).toBeUndefined() - }) -}) - -describe('rows naming a plugin that cannot be resolved', () => { - /** One directory under a fresh root holding `composition`, scanned. */ - async function scanned(composition: string): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-')) - roots.push(root) - await mkdir(join(root, 'probe')) - await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) - const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) - return preset?.broken - } - - it('reports a package the harness cannot resolve, with the row and the name', async () => { - // The way an authored preset actually rots: it named a package that a - // later release renamed, so the composition still parses and still cannot - // compose a session. - expect(await scanned('- id: stale\n name: \'@deepseek-ai/dsh-no-such-package\'\n')) - .toBe('row "stale" names a plugin that cannot be resolved: @deepseek-ai/dsh-no-such-package') - }) - - it('names every unresolvable row rather than only the first', async () => { - // Unlike a parse failure, one unresolvable name tells you nothing about - // the next: fixing them one reload at a time is the avoidable part. - const composition = '- id: a\n name: no-such-a\n- id: b\n name: no-such-b\n' - - expect(await scanned(composition)).toBe( - '2 rows name plugins that cannot be resolved:\n- row "a": no-such-a\n- row "b": no-such-b') - }) - - it('falls back to the row position when a row declares no id', async () => { - // One `row` prefix, not two: the label carries it either way. - expect(await scanned('- name: no-such-plugin\n')) - .toBe('row 1 names a plugin that cannot be resolved: no-such-plugin') - }) - - it('descends into a group and keeps the group in the label', async () => { - const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - name: no-such-plugin\n' - - expect(await scanned(composition)).toMatch(/row 1 row 1 names a plugin that cannot be resolved/) - }) - - it('resolves a preset-relative row against the preset\'s own directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-')) - roots.push(root) - await mkdir(join(root, 'probe')) - await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n') - await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n name: ./own-plugin.mjs\n- id: gone\n name: ./deleted.mjs\n') - - const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) - - // A file the preset ships resolves beside its composition, not from the - // harness — the same split the mount's import override makes. - expect(preset?.broken).toBe('row "gone" names a plugin that cannot be resolved: ./deleted.mjs') - }) - - it('skips a row the loader may never start', async () => { - // `disabled` is the one entry field the Loader interpolates, so a `!!js` - // row cannot be judged from the file; calling a usable preset broken is - // worse than leaving a switched-off row to fail at mount as before. - const composition = '- id: off\n name: no-such-plugin\n disabled: true\n' - + '- id: maybe\n name: no-such-either\n disabled: !!js process.platform === \'win32\'\n' - - expect(await scanned(composition)).toBeUndefined() - }) - - it.each(['false', '0', "''"])('checks a row the loader would start (disabled: %s)', async (value) => { - // The Loader starts a row when `Boolean(options.disabled)` is false, so a - // falsy-but-present value names a row that does run. - expect(await scanned(`- id: on\n name: no-such-plugin\n disabled: ${value}\n`)) - .toMatch(/cannot be resolved/) - }) - - it('reports a file: URL whose target is not there', async () => { - // The Loader accepts a `file:` URL for the same thing an absolute path - // names; a resolver handed one only normalizes it and never looks. - const missing = pathToFileURL(join(tmpdir(), 'dsh-presets-absent', 'nope.mjs')).href - expect(await scanned(`- id: url\n name: '${missing}'\n`)).toMatch(/cannot be resolved/) - }) - - it('reads an installed package off disk without asking the resolver', async () => { - // The fast path, and the one that has to answer alone: this package has a - // directory and nothing to import, so a resolver would reject it. - const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-')) - roots.push(home) - await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true }) - await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n') - await mkdir(join(home, 'presets', 'probe'), { recursive: true }) - await writeFile(join(home, 'presets', 'probe', COMPOSITION_FILE), "- id: p\n name: '@scope/pkg'\n") - - const [preset] = await scanRoot( - { path: join(home, 'presets'), trust: 'user' }, pathToFileURL(join(home, 'app/')).href) - - expect(preset?.broken).toBeUndefined() - }) - - it('reports a package whose install link dangles', async () => { - // What a stale profile install leaves behind: the name is still in - // `node_modules`, pointing at a checkout that is gone. - const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-')) - roots.push(home) - await mkdir(join(home, 'node_modules', '@scope'), { recursive: true }) - await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg')) - await mkdir(join(home, 'presets', 'probe'), { recursive: true }) - await writeFile(join(home, 'presets', 'probe', COMPOSITION_FILE), "- id: p\n name: '@scope/pkg'\n") - - const [preset] = await scanRoot( - { path: join(home, 'presets'), trust: 'user' }, pathToFileURL(join(home, 'app/')).href) - - expect(preset?.broken).toBe('row "p" names a plugin that cannot be resolved: @scope/pkg') - }) - - it('leaves a node builtin alone', async () => { - // Nothing installs `node:fs`, so the disk walk finds nothing; calling a - // composition broken over a name Node always supplies would be a false - // report, which costs more than the row it would have caught. - expect(await scanned('- id: b\n name: node:fs\n')).toBeUndefined() - }) - - it('leaves a cordis builtin alone', async () => { - // A `cordis:` name is supplied by the Loader itself, so there is nothing - // to resolve — as a row of its own, and as the group it recurses into. - expect(await scanned('- id: inc\n name: cordis:include\n config:\n path: ./nested.cordis.yml\n')) - .toBeUndefined() - expect(await scanned('- id: grp\n name: cordis:group\n group: true\n config: []\n')).toBeUndefined() - }) -}) diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/throws.js b/packages/preset/agent-presets/tests/fixtures/plugins/throws.js deleted file mode 100644 index dfd57de705..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/plugins/throws.js +++ /dev/null @@ -1,9 +0,0 @@ -// A row that resolves and then refuses to apply. Discovery's health check -// resolves every row's module without importing it, so a fixture naming a file -// that does not exist can no longer reach the mount; a module that loads and -// throws is what still exercises the loader's own failure reporting. -export const name = 'throws' - -export function apply(_ctx, config) { - throw new Error(config.message) -} diff --git a/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml deleted file mode 100644 index ebd0a74c33..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml +++ /dev/null @@ -1,4 +0,0 @@ -- id: beta - name: ../../plugins/contribute.js - config: - tool: beta diff --git a/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml deleted file mode 100644 index 9a434aec6e..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Shipped preset: one tool plus its guidance section. -- id: alpha - name: ../../plugins/contribute.js - config: - tool: alpha - -# A row switched off in the composition stays off without failing the mount. -- id: alpha-extra - name: ../../plugins/contribute.js - disabled: true - config: - tool: alpha-extra diff --git a/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml deleted file mode 100644 index 740fc6f78e..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml +++ /dev/null @@ -1,8 +0,0 @@ -- id: ok - name: ../../plugins/contribute.js - config: - tool: ok -- id: refuses - name: ../../plugins/throws.js - config: - message: this row refuses to apply diff --git a/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml deleted file mode 100644 index ccb3a9037c..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml +++ /dev/null @@ -1,9 +0,0 @@ -# Accepted: the same provider behind an entry-local realm never reaches the -# root realm, so it is per-session rather than process-global. -- id: svc - name: ../../plugins/global-service.js - isolate: - fixtureIsolatedSvc: true - config: - service: fixtureIsolatedSvc - label: ISOLATED diff --git a/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml deleted file mode 100644 index 895268e67b..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml +++ /dev/null @@ -1,6 +0,0 @@ -# Publishes into the root realm only after the mount audit ran, which only the -# package invariant can catch. -- id: late - name: ../../plugins/late-service.js - config: - service: fixtureLateSvc diff --git a/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml deleted file mode 100644 index f95329ce46..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Rejected: publishes services into the root realm, which would be -# process-global rather than per-session. Two rows, so the diagnostic has to -# order the names it reports. -- id: leak-z - name: ../../plugins/global-service.js - config: - service: zzzFixtureLeakedSvc - label: LEAKED-Z -- id: leak-a - name: ../../plugins/global-service.js - config: - service: aaaFixtureLeakedSvc - label: LEAKED-A diff --git a/packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml deleted file mode 100644 index e3b3265ecd..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml +++ /dev/null @@ -1,16 +0,0 @@ -# Two failing rows inside a group. The Loader wraps the group's own -# AggregateError into a plain Error carrying only its message, so without -# following `cause` the diagnostic ends at "loader entries failed to apply" and -# names neither row. -- id: outer - name: cordis:group - group: true - config: - - id: inner-first - name: ../../plugins/throws.js - config: - message: inner first refuses - - id: inner-second - name: ../../plugins/throws.js - config: - message: inner second refuses diff --git a/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt deleted file mode 100644 index b4a2550351..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt +++ /dev/null @@ -1 +0,0 @@ -placeholder, not a preset diff --git a/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml deleted file mode 100644 index 67f7ffb09a..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml +++ /dev/null @@ -1,2 +0,0 @@ -- id: waits - name: ../../plugins/needs-missing.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml deleted file mode 100644 index 4cfbbcb20c..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml +++ /dev/null @@ -1,5 +0,0 @@ -# Same id as the shipped preset: proves the earlier root wins. -- id: shadowed - name: ../../plugins/contribute.js - config: - tool: shadowed diff --git a/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml deleted file mode 100644 index 91b109d5f2..0000000000 --- a/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Two rows that cannot apply: the Loader reports several failed entries as one -# AggregateError whose own message names none of them, so this fixture is what -# proves the mount diagnostic flattens the causes. -- id: first-refuses - name: ../../plugins/throws.js - config: - message: first row refuses -- id: second-refuses - name: ../../plugins/throws.js - config: - message: second row refuses diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts deleted file mode 100644 index e1fb150b5c..0000000000 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { describe, expect, it } from 'vitest' -import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets' -import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const ROOTS = [ - { path: join(FIXTURES, 'system'), trust: 'system' as const }, - { path: join(FIXTURES, 'user'), trust: 'user' as const }, -] - -async function harness(roster: Partial = {}): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, ...roster }) - await ctx.plugin(InvariantRegistry) - await ctx.plugin(AgentPresetsInvariant) - return ctx -} - -describe('agent-presets invariants', () => { - it('keeps the standing composition alive across the agents that joined it', async () => { - const ctx = await harness() - const handle = await ctx.agents.create({ - sessionId: SessionId('inv-live'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - - expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') - - // A standing mount survives its agents: the composition a session joined - // is shared, so one session ending must not strip it from the next. - await handle.dispose() - expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') - - // A second agent reuses the same mount rather than adding one. - await ctx.agents.create({ - sessionId: SessionId('inv-live-2'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1) - - // Whole-tree teardown is the boundary that does reclaim it. - await ctx.fiber.dispose() - expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard') - }) - - it('rejects a composition that publishes a process-global service after its audit', async () => { - const ctx = await harness() - await ctx.agents.create({ - sessionId: SessionId('inv-late'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'late'), - }) - const publishLate = (globalThis as { __PUBLISH_LATE__?: () => void }).__PUBLISH_LATE__ - expect(publishLate).toBeTypeOf('function') - - expect(() => { publishLate?.() }).toThrow(/published process-global service\(s\) \[fixtureLateSvc\]/) - }) - - it('stays quiet while every composition keeps its services out of the root realm', async () => { - const ctx = await harness() - - await expect(ctx.agents.create({ - sessionId: SessionId('inv-isolated'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), - })).resolves.toBeDefined() - }) - - 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 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(assembleContextFor(handle.agent))) - .rejects.toThrow(/without joining any agent preset/) - }) - - it('rejects one just the same when the derived home root is the whole roster', async () => { - // The shape this plugin defaults to: an app configures nothing and the - // roster is the harness home alone. A roster is a roster however its roots - // were resolved, so the fail-loud half must not go quiet here — it read - // `config.roots` once, which is empty in exactly this case. - const ctx = await harness({ roots: [], includeUserRoot: true }) - const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') }) - - await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) - .rejects.toThrow(/without joining any agent preset/) - }) - - it('stays silent for a composition that opted out of every root', async () => { - // `includeUserRoot: false` with no configured roots is a deployment that - // mounts the roster but keeps its agents on the host plane; there is no - // roster to join, so an unjoined agent is not a violation. - const ctx = await harness({ roots: [], includeUserRoot: false }) - const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') }) - - await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() - }) - - 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(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() - }) -}) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts deleted file mode 100644 index 99ed7b97f8..0000000000 --- a/packages/preset/agent-presets/tests/metadata.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Display metadata is presentation, never capability: every way of getting it - * wrong degrades to "this preset has no display text" rather than to a - * preset that cannot be discovered or mounted. It also cannot carry identity - * — `id` is the directory and `trust` is the root, so neither is readable - * from the file a user can write. - */ - -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts' - -/** Every temp preset directory created by this file, removed after each test. */ -const tempDirs: string[] = [] -afterEach(async () => { - for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) -}) - -/** A preset directory holding exactly the given metadata text. */ -async function presetDir(content?: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-')) - tempDirs.push(dir) - await mkdir(dir, { recursive: true }) - if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content) - return dir -} - -describe('reading display metadata', () => { - it('reads a name and a description', async () => { - const dir = await presetDir('name: 标准模式\ndescription: 完整的编码 agent。\n') - - expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', description: '完整的编码 agent。' }) - }) - - it('treats an absent file as no metadata', async () => { - // The common case: every preset authored by duplicating another starts - // without one, and a picker simply falls back to the id. - expect(await readPresetMetadata(await presetDir())).toEqual({}) - }) - - it('treats malformed YAML as no metadata', async () => { - const dir = await presetDir('name: [unclosed\n') - - // Display text is not worth failing discovery over — the composition - // beside it still mounts. - expect(await readPresetMetadata(dir)).toEqual({}) - }) - - it.each([ - ['a list', '- name: x\n'], - ['a scalar', 'just a string\n'], - ['an empty document', ''], - ])('treats %s as no metadata', async (_label, content) => { - expect(await readPresetMetadata(await presetDir(content))).toEqual({}) - }) - - it('ignores fields that are not text', async () => { - const dir = await presetDir('name: 42\ndescription:\n nested: true\n') - - expect(await readPresetMetadata(dir)).toEqual({}) - }) - - it('ignores blank text rather than showing an empty name', async () => { - const dir = await presetDir('name: " "\ndescription: ""\n') - - expect(await readPresetMetadata(dir)).toEqual({}) - }) - - it('trims surrounding whitespace', async () => { - const dir = await presetDir('name: " 极简模式 "\n') - - expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' }) - }) - - it('reads a declared order', async () => { - const dir = await presetDir('name: 标准模式\norder: 1\n') - - expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', order: 1 }) - }) - - it('ignores an order that is not a finite number', async () => { - expect(await readPresetMetadata(await presetDir('order: first\n'))).toEqual({}) - expect(await readPresetMetadata(await presetDir('order: .inf\n'))).toEqual({}) - }) - - it('cannot carry identity or trust', async () => { - const dir = await presetDir('name: mine\nid: standard\ntrust: system\n') - - // A locally authored preset writing `trust: system` must not become a - // shipped one; identity comes from the directory and the root it sits in. - expect(await readPresetMetadata(dir)).toEqual({ name: 'mine' }) - }) -}) - -describe('rendering display metadata', () => { - it('round-trips through a read', async () => { - const rendered = renderPresetMetadata({ name: '创造模式', description: '可以改自己的组装。' }) - const dir = await presetDir(rendered) - - expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' }) - }) - - it('stores a declared order', () => { - expect(renderPresetMetadata({ name: '标准模式', order: 1 })).toBe('name: 标准模式\norder: 1\n') - }) - - it('omits an absent field rather than writing it blank', () => { - expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n') - // Description without a name is legal too: the picker falls back to the id. - expect(renderPresetMetadata({ description: '只做检索。' })).toBe('description: 只做检索。\n') - }) - - it('renders nothing when there is nothing to store', () => { - // Clearing both fields removes the file; an empty document would read as - // an intentional blank name. - expect(renderPresetMetadata({})).toBeUndefined() - expect(renderPresetMetadata({ name: ' ', description: '' })).toBeUndefined() - }) -}) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts deleted file mode 100644 index 417aaa1d1d..0000000000 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import Group from '@deepseek-ai/cordis-plugin-group' -import { PluginPackages } from '@deepseek-ai/dsh-app-boot' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import AgentPresets, { - COMPOSITION_FILE, inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, -} from '@deepseek-ai/dsh-agent-presets' -import type { Config } from '@deepseek-ai/dsh-agent-presets' -import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' - -declare module '@deepseek-ai/cordis' { - interface Context { - /** Published by the `isolated` fixture preset behind an entry-local realm. */ - fixtureIsolatedSvc: { label: string } - } -} - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const ROOTS = [ - { path: join(FIXTURES, 'system'), trust: 'system' as const }, - { path: join(FIXTURES, 'user'), trust: 'user' as const }, -] - -/** - * A composition carrying the registries a preset contributes to, plus the - * preset roster. - * @param roster - roster config, defaulting to the fixture roots. - * @returns the booted context. - */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(PluginPackages) - ctx.loader.builtins.include = Include - // A preset outside this workspace cannot resolve `cordis-plugin-group` by - // name, so the app registers it as a builtin; the fixtures compose the same - // way real presets do, which needs it here too. - ctx.loader.builtins.group = Group - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, roster) - return ctx -} - -async function agentOn(ctx: Context, id: string, presetId?: string): Promise { - const handle = await ctx.agents.create({ - sessionId: SessionId(id), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId), - }) - return handle.agent -} - -const toolNames = (ctx: Context, agent?: Agent): string[] => - ctx.tools.schemas(agent).map(schema => schema.name).sort() - -/** Every service registration in the runtime, regardless of which realm holds it. */ -function providedServiceNames(ctx: Context): string[] { - const store = ctx.reflect.store - return Object.getOwnPropertySymbols(store) - .map(key => store[key]?.name) - .filter((name): name is string => name !== undefined) -} - -/** Whether the root realm maps `name` to a live registration. */ -function rootResolves(ctx: Context, name: string): boolean { - const key = ctx.root[Context.isolate][name] - return key !== undefined && ctx.reflect.store[key] !== undefined -} - -let ctx: Context - -/** Every temp preset root created by this file, removed after each test. */ -const roots: string[] = [] -afterEach(async () => { - for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -beforeEach(async () => { - ctx = await harness() -}) - -describe('composing an agent from a preset', () => { - it('hands an absolute plugin path to Node as a file URL', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) - roots.push(root) - const presetDir = join(root, 'absolute') - const plugin = join(FIXTURES, 'plugins', 'contribute.js') - await mkdir(presetDir) - await writeFile( - join(presetDir, COMPOSITION_FILE), - `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, - ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeShippedRoot: false, includeUserRoot: false }) - const imported = vi.spyOn(scoped.loader.internal!, 'import') - - await agentOn(scoped, 'sess-absolute-plugin') - - expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {}) - }) - - it('gives each session only its own preset\'s tools', async () => { - const alpha = await agentOn(ctx, 'sess-alpha', 'standard') - const beta = await agentOn(ctx, 'sess-beta', 'minimal') - - expect(toolNames(ctx, alpha)).toEqual(['alpha']) - expect(toolNames(ctx, beta)).toEqual(['beta']) - expect(toolNames(ctx)).toEqual([]) - }) - - it('scopes prompt sections and assembled schemas to the same session', async () => { - const alpha = await agentOn(ctx, 'sess-alpha', 'standard') - const beta = await agentOn(ctx, 'sess-beta', 'minimal') - - const alphaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(alpha)) - const betaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(beta)) - - expect(alphaPrompt.sections.map(section => section.name)).toContain('preset:alpha') - expect(alphaPrompt.sections.map(section => section.name)).not.toContain('preset:beta') - expect(betaPrompt.sections.map(section => section.name)).toContain('preset:beta') - expect(alphaPrompt.tools.map(schema => schema.name)).toEqual(['alpha']) - }) - - it('mounts the default preset when the caller names none', async () => { - const agent = await agentOn(ctx, 'sess-default') - - expect(toolNames(ctx, agent)).toEqual(['alpha']) - }) - - it('lets two sessions share one preset without colliding', async () => { - const first = await agentOn(ctx, 'sess-first', 'standard') - const second = await agentOn(ctx, 'sess-second', 'standard') - - expect(toolNames(ctx, first)).toEqual(['alpha']) - expect(toolNames(ctx, second)).toEqual(['alpha']) - }) - - it('unwinds one session\'s composition without touching another\'s', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('sess-gone'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - const survivor = await agentOn(ctx, 'sess-stays', 'minimal') - expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) - - await handle.dispose() - - expect(ctx.agents.get(SessionId('sess-gone'))).toBeUndefined() - expect(toolNames(ctx, survivor)).toEqual(['beta']) - expect(toolNames(ctx)).toEqual([]) - }) -}) - -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 { - 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('reports import failures and arbitrary plugin rejections after eager settlement', async () => { - ctx.loader.builtins.stringFailure = () => { throw 'string rejection' } - ctx.loader.builtins.aggregateFailure = () => { - throw new AggregateError([ - new Error('first member'), - new Error('wrapped member', { cause: new AggregateError(['nested member'], 'nested aggregate') }), - ], 'aggregate rejection') - } - await ctx.loader.root.update([ - { id: 'missing', name: 'cordis:missingBuiltin' }, - { id: 'disabled', name: 'cordis:missingBuiltin', disabled: true }, - { id: 'string', name: 'cordis:stringFailure' }, - { id: 'aggregate', name: 'cordis:aggregateFailure' }, - ]) - expect(await inactiveRows(ctx.loader)).toEqual([ - 'missing (cordis:missingBuiltin): never started', - 'string (cordis:stringFailure): string rejection', - 'aggregate (cordis:aggregateFailure): aggregate rejection\n- first member\n- wrapped member\n - nested member', - ]) - }) - - it('refuses to mount into a context that carries no agent scope', async () => { - await expect(ctx.agentPresets.mount(ctx, 'standard')) - .rejects.toThrow(/unscoped context/) - }) - - it('rolls the whole agent back when a row fails to load', async () => { - await expect(agentOn(ctx, 'sess-broken', 'broken')).rejects.toThrow(/failed to mount/) - - expect(ctx.agents.get(SessionId('sess-broken'))).toBeUndefined() - expect(toolNames(ctx)).toEqual([]) - }) - - it('names every failed row, not just the count', async () => { - await expect(agentOn(ctx, 'sess-two-broken', 'two-broken')) - .rejects.toThrow(/first-refuses[\s\S]*second-refuses/) - }) - - it('names the rows inside a failed group, not the group alone', async () => { - await expect(agentOn(ctx, 'sess-nested-broken', 'nested-broken')) - .rejects.toThrow(/inner-first[\s\S]*inner-second/) - }) - - it('names the unresolved service when a row never activates', async () => { - await expect(agentOn(ctx, 'sess-pending', 'pending')) - .rejects.toThrow(/waiting for serviceThatDoesNotExist/) - }) - - it('rejects a row that publishes a process-global service', async () => { - await expect(agentOn(ctx, 'sess-leaky', 'leaky')) - .rejects.toThrow(/process-global service\(s\) \[aaaFixtureLeakedSvc, zzzFixtureLeakedSvc\]/) - - // The rejected subtree is fully unwound, so its registrations are gone from - // the store rather than merely unreachable. - expect(providedServiceNames(ctx)).not.toContain('aaaFixtureLeakedSvc') - expect(providedServiceNames(ctx)).not.toContain('zzzFixtureLeakedSvc') - }) - - it('accepts the same provider behind an isolate realm', async () => { - const agent = await agentOn(ctx, 'sess-isolated', 'isolated') - - expect(agent.id).toBe(SessionId('sess-isolated')) - // The provider ran, but under a realm-private symbol the root cannot reach. - expect(providedServiceNames(ctx)).toContain('fixtureIsolatedSvc') - expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) - }) - - it('addresses the standing instance of a realm-private service through either agent', async () => { - const first = await agentOn(ctx, 'sess-reach-a', 'isolated') - const second = await agentOn(ctx, 'sess-reach-b', 'isolated') - - // The realm keeps the service out of every host context, so a caller - // holding the agent is how a request from OUTSIDE the session reads the - // instance it is about. - expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) - const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc') - const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc') - expect(mine).toBeDefined() - // ONE composition per preset: both agents joined the same standing mount, - // so they address the same instance — sessions stay apart inside it by - // the plugin's own Session/Agent keying, not by instance count. - expect(theirs).toBe(mine) - }) - - it('answers undefined for a service the agent\'s preset does not mount', async () => { - // The isolated preset's standing instance exists in the same runtime, so - // the lookup finds the NAME and must still refuse it: the instance lives - // under another mount's fiber, not this agent's composition. - await agentOn(ctx, 'sess-reach-other', 'isolated') - const agent = await agentOn(ctx, 'sess-reach-none', 'standard') - - expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined() - }) - - it('answers undefined for an agent outside the scope machinery', async () => { - // Unscoped, scoped-but-unparented, and parented to a key no live mount - // owns are the three ways a context can fail to name a standing mount; - // each is an answer, not a throw, because the caller asked a question. - expect(serviceForAgent(ctx, { ctx }, 'fixtureIsolatedSvc')).toBeUndefined() - const loner = createScope(ctx, { test: 'loner' }) - expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() - const orphan = createScope(ctx, { test: 'orphan' }) - bindScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) - expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() - }) - - it('refuses to mount a preset directly into an unscoped context', async () => { - // The service's own mount() guards this before delegating; the exported - // function is callable on its own, so the boundary holds there too. - const preset = await ctx.agentPresets.resolve('standard') - - await expect(mountPreset(ctx, preset)).rejects.toThrow(/unscoped context/) - }) - - it('reports the known ids when a preset is unknown', async () => { - await expect(ctx.agentPresets.resolve('nope')) - .rejects.toThrow(/preset "nope" not found \(available: .*standard/) - }) -}) - -describe('the preset roster', () => { - it('lists every root\'s presets with the earlier root winning', async () => { - const listed = await ctx.agentPresets.list() - - // `not-a-preset` is the fixture ghost: no composition file, listed broken. - expect(listed.map(preset => preset.id).sort()) - .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'nested-broken', 'not-a-preset', 'pending', 'standard', 'two-broken']) - expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system') - expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/) - }) - - it('uses the profile package service when checking bare package rows', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-profile-package-')) - roots.push(root) - const presetDir = join(root, 'profile-package') - await mkdir(presetDir) - await writeFile(join(presetDir, COMPOSITION_FILE), '- id: package\n name: profile-package/plugin.js\n') - const scoped = await harness({ - default: 'profile-package', roots: [{ path: root, trust: 'user' }], - includeShippedRoot: false, includeUserRoot: false, - }) - const packageOf = vi.spyOn(scoped.pluginPackages, 'packageOf').mockReturnValue({ - name: 'profile-package', version: '1.0.0', dir: presetDir, - manifestPath: join(presetDir, 'package.json'), manifest: {}, - }) - - const [listed] = await scoped.agentPresets.list() - expect(listed).toMatchObject({ id: 'profile-package', trust: 'user' }) - expect(listed?.broken).toBeUndefined() - expect(packageOf).toHaveBeenCalledWith('profile-package/plugin.js', pathToFileURL(FIXTURES).href + '/') - }) - - it('isolates a package lookup failure to the preset being checked', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-package-failure-')) - roots.push(root) - await mkdir(join(root, 'healthy')) - await writeFile(join(root, 'healthy', COMPOSITION_FILE), '[]\n') - await mkdir(join(root, 'lookup-failure')) - await writeFile( - join(root, 'lookup-failure', COMPOSITION_FILE), - '- id: package\n name: profile-package/plugin.js\n', - ) - const scoped = await harness({ - default: 'healthy', roots: [{ path: root, trust: 'user' }], - includeShippedRoot: false, includeUserRoot: false, - }) - const packageOf = vi.spyOn(scoped.pluginPackages, 'packageOf').mockImplementation(() => { - throw new Error('profile package lookup failed') - }) - - const listed = await scoped.agentPresets.list() - expect(listed.find(preset => preset.id === 'lookup-failure')?.broken) - .toBe("the composition's plugins cannot be checked: profile package lookup failed") - expect(listed.find(preset => preset.id === 'healthy')?.broken).toBeUndefined() - await expect(agentOn(scoped, 'sess-after-package-failure')).resolves.toBeDefined() - - packageOf.mockImplementation(() => { - throw 'raw profile package lookup failed' - }) - const rawListed = await scoped.agentPresets.list() - expect(rawListed.find(preset => preset.id === 'lookup-failure')?.broken) - .toBe("the composition's plugins cannot be checked: raw profile package lookup failed") - }) - - it('exposes the configured default id', () => { - expect(ctx.agentPresets.defaultId).toBe('standard') - }) -}) - -describe('composing from a broken preset', () => { - /** A roster whose only user preset carries `composition`. */ - async function rosterWith(composition: string): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) - roots.push(root) - await mkdir(join(root, 'damaged')) - await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) - } - - it('refuses the mount up front with the discovery-reported reason', async () => { - const scoped = await rosterWith('- id: x\n name: [unclosed\n') - - // The refusal happens before the loader ever sees the file, so every - // unloadable shape gets the same early agent-preset/invalid — and a - // rejected setup rolls the whole agent creation back. - await expect(agentOn(scoped, 'sess-broken', 'damaged')) - .rejects.toMatchObject({ code: 'agent-preset/invalid' }) - await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/) - expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0) - }) - - it('refuses the standing key a cold reader would mount by', async () => { - const scoped = await rosterWith('rows: not-a-list\n') - - await expect(scoped.agentPresets.standingKeyFor('damaged')) - .rejects.toThrow(/top-level list of plugin rows/) - }) - - it('still resolves the broken row for the surfaces that manage it', async () => { - const scoped = await rosterWith('- id: x\n name: [unclosed\n') - - // Deleting and reporting need the row; only composing refuses it. - expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/) - }) -}) - -describe('a roster with nothing in it', () => { - it('says so instead of naming an empty list of candidates', async () => { - const bare = new Context() - bare.baseUrl = pathToFileURL(FIXTURES).href + '/' - await bare.plugin(Loader) - await bare.plugin(SessionProjectionRegistry) - await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) - - await expect(bare.agentPresets.resolve()) - .rejects.toThrow(/preset "standard" not found \(available: none\)/) - }) -}) - -describe('a roster with no base to resolve from', () => { - it('refuses at load rather than calling every preset broken', async () => { - // Health answers "can this row be imported?", and the same package name - // fails from a preset's own directory while resolving from the installed - // harness. Without the base there is no answer, and the silent one is - // exactly the failure the check exists to report. - const baseless = new Context() - await baseless.plugin(Loader) - await baseless.plugin(SessionProjectionRegistry) - - await expect(baseless.plugin(AgentPresets, { - default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, - })).rejects.toThrow(/needs `ctx\.baseUrl`/) - }) -}) - -describe('the preset file is an input, never a persistence target', () => { - it('survives a row that disposes itself, which makes the Loader persist a tree', async () => { - // The preset lives in a temp root, not under `fixtures/`: without the - // `write()` override the Loader REWRITES the composition it read, so a - // committed fixture would be mutated by the very run that proves the bug - // and every later run would compare against the damaged file and pass. - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-')) - roots.push(root) - const dir = join(root, 'self-disposing') - await mkdir(dir) - const path = join(dir, COMPOSITION_FILE) - const composition = [ - '- id: tool-kept', - ` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`, - ' config:', - ' tool: kept', - '- id: goes-away', - ` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`, - '', - ].join('\n') - await writeFile(path, composition) - - const scoped = new Context() - scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' - await scoped.plugin(Loader) - scoped.loader.builtins.include = Include - scoped.loader.builtins.group = Group - await scoped.plugin(LlmRuntime) - await scoped.plugin(SessionStore) - await scoped.plugin(SessionProjectionRegistry) - await scoped.plugin(SystemPrompt, { personaPrefix: '' }) - await scoped.plugin(ToolRuntime) - await scoped.plugin(AgentRegistry) - await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) - - await scoped.agents.create({ - sessionId: SessionId('sess-self-dispose'), - setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx), - }) - await (globalThis as { __SELF_DISPOSED__?: Promise }).__SELF_DISPOSED__ - // Slack past the deterministic signal above, not a race the number has to - // win. The write rides the Loader's fiber-unload listener, which stamps - // `disabled: true` and calls `write()` in the same synchronous step; once - // the self-dispose has settled, a regression has already written. Polling - // would not help — the assertion is an ABSENCE, and no amount of waiting - // proves one — so the wait only has to clear settlement. - await new Promise(resolve => setTimeout(resolve, 50)) - - // Inherited, `EntryTree.write()` persists the dying tree — stamping - // `disabled: true` onto the row and, in the shipped case, truncating the - // composition every session shares. - expect(await readFile(path, 'utf8')).toBe(composition) - }) -}) - -describe('attributing a service to a subtree', () => { - it('attributes nothing to a subtree that is already torn down', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('sess-torn'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - const [mount] = livePresetMounts().filter(entry => entry.presetId === 'standard') - expect(mount).toBeDefined() - - await handle.dispose() - - // A disposed subtree owns nothing, so it can never be blamed for a service - // some other subtree published under the same name afterwards. - expect(leakedServices(ctx, mount!.fiber)).toEqual([]) - }) -}) - -describe('replacing a composition', () => { - it('publishes a committed preset selection for remote consumers', async () => { - const agent = await agentOn(ctx, 'sess-selected', 'standard') - const selected: Array<[SessionId, string]> = [] - ctx.on('agent-preset/selected', (sessionId, agentPreset) => { - selected.push([sessionId, agentPreset]) - }) - - agent.session.append('agent-preset/selected', { agentPreset: 'minimal' }) - - expect(selected).toEqual([[SessionId('sess-selected'), 'minimal']]) - }) - - it('swaps the agent\'s tools without touching another session', async () => { - const keeper = await agentOn(ctx, 'sess-keeper', 'standard') - const handle = await ctx.agents.create({ - sessionId: SessionId('sess-swap'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) - - await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') - - expect(toolNames(ctx, handle.agent)).toEqual(['beta']) - expect(toolNames(ctx, keeper)).toEqual(['alpha']) - expect(toolNames(ctx)).toEqual([]) - }) - - it('notifies tool views after reparenting and contains notification failures', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('sess-tool-change'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - await ctx.agentPresets.standingKeyFor('minimal') - let changes = 0 - const stopCounting = ctx.on('tools/change', () => { changes += 1 }) - await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') - expect(changes).toBe(1) - stopCounting() - - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - const stopThrowing = ctx.on('tools/change', () => { throw new Error('listener failed') }) - await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'standard')).resolves.toMatchObject({ id: 'standard' }) - expect(ctx.agentPresets.composedPreset(handle.agent.ctx)).toBe('standard') - expect(warnings).toEqual([expect.stringContaining('tools/change listener failed')]) - stopThrowing() - }) - - it('leaves the agent on its previous composition when the new one is unknown', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('sess-unknown'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - - await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'nope')) - .rejects.toThrow(/not found/) - - // Resolution happens before any teardown, so an unknown id is a no-op. - expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) - }) - - it('restores the previous composition when the new one fails to mount', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('sess-restore'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), - }) - - await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) - .rejects.toThrow(/failed to mount/) - - // The swap is unmount-then-mount, so a failure must put the old one back - // rather than leave the agent with no tools at all. - expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) - }) - - it('names an agent that was published without joining any preset', async () => { - const ctx = await harness() - const warnings: string[] = [] - ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn - - await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') }) - // Advisory, not fatal: a synchronous `agent/created` throw would veto - // publication, and creating an agent outside the roster stays legal. - expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1) - expect(warnings.at(-1)).toMatch(/without joining an agent preset/) - - warnings.length = 0 - await agentOn(ctx, 'sess-joined-quiet', 'minimal') - expect(warnings).toEqual([]) - }) - - it('says nothing when the composition opts out of every root', async () => { - // Presets are optional: every surface except the Web bundle keeps its - // model-facing rows in the host plane, so an agent with a chain of one is - // exactly right there and the diagnostic must stay silent. Opting out is - // what makes this rosterless — empty `roots` alone would still derive the - // harness-home root, which is a roster like any other. - const rosterless = await harness({ default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) - const warnings: string[] = [] - rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn - - await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') }) - - expect(warnings).toEqual([]) - }) - - it('composes an agent that had nothing installed', async () => { - // An agent created without a preset has no binding to re-link, so the - // switch is its first bind — exactly a mount — and once bound only the - // roster's kept binding can move it again. - const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) - - await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') - - expect(toolNames(ctx, handle.agent)).toEqual(['beta']) - }) - - it('refuses a bare agent\'s broken composition without restoring anything', async () => { - const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') }) - - await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) - .rejects.toThrow(/failed to mount/) - - // Nothing was installed, so there is nothing to put back. - expect(toolNames(ctx, handle.agent)).toEqual([]) - }) - - it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => { - // A preset root this test owns, so removing the composition mid-flight - // cannot disturb the shipped fixtures. - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-')) - roots.push(root) - const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', `- id: nope\n name: ${join(FIXTURES, 'plugins', 'throws.js')}\n config:\n message: refuses\n`]] - for (const [id, body] of seeded) { - await mkdir(join(root, id)) - await writeFile(join(root, id, COMPOSITION_FILE), body) - } - const scoped = new Context() - scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' - await scoped.plugin(Loader) - scoped.loader.builtins.include = Include - scoped.loader.builtins.group = Group - await scoped.plugin(LlmRuntime) - await scoped.plugin(SessionStore) - await scoped.plugin(SessionProjectionRegistry) - await scoped.plugin(SystemPrompt, { personaPrefix: '' }) - await scoped.plugin(ToolRuntime) - await scoped.plugin(AgentRegistry) - await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) - const handle = await scoped.agents.create({ - sessionId: SessionId('sess-restore-gone'), - setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), - }) - - // The roster is a live directory: the composition the agent came from can - // be gone from DISK by the time a switch fails. The standing mount is not - // the file — it outlives deletion, so there is nothing to "restore". - await rm(join(root, 'first'), { recursive: true }) - - await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken')) - .rejects.toThrow(/failed to mount/) - - // The failed switch left the agent EXACTLY as it was: the new standing - // mount is ensured before the parent link moves, so a rejection never - // strips the old composition. - expect(toolNames(scoped, handle.agent)).toEqual(['only']) - }) - - it('refuses an unscoped context', async () => { - await expect(ctx.agentPresets.recompose(ctx, 'minimal')) - .rejects.toThrow(/unscoped context/) - }) -}) - -describe('editing a composition file', () => { - /** One-row composition whose single tool is named `tool`. */ - const rowFor = (tool: string): string => - `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: ${tool}\n` - - /** - * A context over a temp root holding one editable preset. The id is - * per-test because `livePresetMounts()` is a process-global registry. - */ - async function editable(id: string): Promise<{ scoped: Context; path: string }> { - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-')) - roots.push(root) - await mkdir(join(root, id)) - const path = join(root, id, COMPOSITION_FILE) - await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) - return { scoped, path } - } - - it('starts a new generation for later sessions while joined ones keep theirs', async () => { - const { scoped, path } = await editable('edited') - const first = await agentOn(scoped, 'sess-gen-first', 'edited') - expect(toolNames(scoped, first)).toEqual(['before']) - - // Files are the only composition editor now (authoring is copy/delete), - // so the standing mount notices the file's stamp changing on its own. - await writeFile(path, rowFor('afterwards')) - - const second = await agentOn(scoped, 'sess-gen-second', 'edited') - expect(toolNames(scoped, second)).toEqual(['afterwards']) - // The joined session keeps the generation it runs on. - expect(toolNames(scoped, first)).toEqual(['before']) - }) - - it('gives two sessions racing the refreshed file one shared new generation', async () => { - const { scoped, path } = await editable('raced') - await agentOn(scoped, 'sess-race-seed', 'raced') - - await writeFile(path, rowFor('afterwards')) - - // Whichever racer swaps the pointer first, the other must join it rather - // than fork a third generation off the same edit. - const [left, right] = await Promise.all([ - agentOn(scoped, 'sess-race-left', 'raced'), - agentOn(scoped, 'sess-race-right', 'raced'), - ]) - expect(toolNames(scoped, left)).toEqual(['afterwards']) - expect(toolNames(scoped, right)).toEqual(['afterwards']) - expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) - }) - - it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => { - const { scoped, path } = await editable('guarded-refresh') - const preset = await scoped.agentPresets.resolve('guarded-refresh') - await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh') - const service = scoped.agentPresets as unknown as { - standing: Map> - ensureStanding(current: typeof preset): Promise - } - const stalePromise = service.standing.get(preset.id)! - const stale = await stalePromise - await writeFile(path, rowFor('afterwards')) - const { mtimeMs, size } = await stat(path) - const newer = { ...stale, stamp: { mtimeMs, size } } - const newerPromise = Promise.resolve(newer) - - // `await pending` yields before the guarded delete, letting the winning - // refresher replace the pointer deterministically instead of by timing. - const refresh = service.ensureStanding(preset) - service.standing.set(preset.id, newerPromise) - - expect(await refresh).toBe(newer) - expect(service.standing.get(preset.id)).toBe(newerPromise) - }) - - it('hands a host reader the standing key without starting an agent', async () => { - const { scoped } = await editable('cold-read') - - const key = await scoped.agentPresets.standingKeyFor('cold-read') - - // The mount exists for the reader; no agent, session, or turn started. - expect(key).toEqual({ agentPreset: 'cold-read' }) - expect(livePresetMounts().filter(mount => mount.presetId === 'cold-read')).toHaveLength(1) - expect(scoped.agents.get(SessionId('cold-read'))).toBeUndefined() - // A second reader resolves the same generation, not a new mount. - expect(await scoped.agentPresets.standingKeyFor('cold-read')).toBe(key) - }) - - it('refuses to mount a generation it cannot stamp', async () => { - const { scoped, path } = await editable('unstampable') - await rm(path) - - // Discovery would refuse the preset too; a caller that resolved just - // before the deletion must get a mount failure, not an unstamped - // generation that no later edit could ever refresh. - const racer = scoped.agentPresets as unknown as { - ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise - } - await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path })) - .rejects.toMatchObject({ code: 'agent-preset/invalid' }) - expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0) - }) - - it('keeps serving the mounted generation when the file cannot be statted', async () => { - const { scoped, path } = await editable('stale') - await agentOn(scoped, 'sess-stale-served', 'stale') - expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1) - - await rm(path) - - // Discovery refuses a preset whose composition cannot be statted, so the - // public route cannot reach this state — but a caller that resolved just - // before the deletion still can, and it must be served the standing - // generation rather than failed over a stat. - const racer = scoped.agentPresets as unknown as { - ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise - } - await racer.ensureStanding({ id: 'stale', trust: 'user', path }) - - expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1) - }) -}) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts deleted file mode 100644 index d5ac6f1f6d..0000000000 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ /dev/null @@ -1,473 +0,0 @@ -/** - * The agent-preset Remote namespace: the path-free roster a client reads, the - * composition view behind the read-only viewer, and the per-session switch — - * which is the only one of the three that mutates an agent. - */ - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { remoteErrorOf, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' -import { afterEach, describe, expect, it, vi } from 'vitest' -import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' -import type { Config } from '@deepseek-ai/dsh-agent-presets' -import type {} from '@deepseek-ai/dsh-agent-presets/types' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const ROOTS = [ - { path: join(FIXTURES, 'system'), trust: 'system' as const }, - { path: join(FIXTURES, 'user'), trust: 'user' as const }, -] -// A row naming a package, the way an authored preset's rows do. Health -// resolves every row it can prove will start, so a path reaching outside the -// temp preset directory these tests seed would report the composition broken. -const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' - -/** Every temp preset root created by this file, removed after each test. */ -const roots: string[] = [] - -afterEach(async () => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -async function remoteFailure(operation: Promise): Promise { - try { - await operation - } catch (error: unknown) { - const failure = remoteErrorOf(error) - if (failure === undefined) throw error - return failure - } - throw new Error('expected the Remote operation to fail') -} - -function availableOf(failure: RemoteFailure): readonly string[] { - if (!('available' in failure.details)) throw new Error('expected available preset ids') - const available: unknown = failure.details.available - if (!Array.isArray(available) - || !available.every((value: unknown): value is string => typeof value === 'string')) { - throw new Error('expected available preset ids') - } - return available -} - -function reasonOf(failure: RemoteFailure): string { - if (!('reason' in failure.details)) throw new Error('expected a preset failure reason') - const reason: unknown = failure.details.reason - if (typeof reason !== 'string') throw new Error('expected a preset failure reason') - return reason -} - -async function harness( - roster: Config = { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }, -): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, roster) - return ctx -} - -async function agentOn(ctx: Context, id: string, presetId?: string): Promise { - const handle = await ctx.agents.create({ - sessionId: SessionId(id), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId), - }) - return handle.agent -} - -/** The recorded preset a restart replays, which is what a switch must move. */ -const recordedPreset = (agent: Agent): unknown => - agent.session.snapshotEvents().findLast(event => event.type === 'agent-preset/selected')?.data - -describe('the roster a client reads', () => { - it('projects path-free rows, marking the default and carrying published metadata', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) - roots.push(userRoot) - await mkdir(join(userRoot, 'documented'), { recursive: true }) - await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID) - await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n') - const ctx = await harness({ - default: 'minimal', - roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - const roster = await ctx.agentPresets.remoteExportList() - - expect(roster.authorable).toBe(true) - expect(roster.modeSelectionEnabled).toBe(true) - expect(roster.presets).toEqual([ - { id: 'minimal', trust: 'system', isDefault: true }, - { id: 'standard', trust: 'system', isDefault: false }, - { id: 'documented', trust: 'user', isDefault: false, name: '我的模式', description: '只做检索。' }, - ]) - // No row carries the composition's location: a preset is addressed by id - // everywhere off the Host. - expect(roster.presets.every(row => !('path' in row))).toBe(true) - }) - - it('keeps a broken preset on the roster with its reason', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) - roots.push(userRoot) - await mkdir(join(userRoot, 'damaged'), { recursive: true }) - const ctx = await harness({ - default: 'standard', - roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - const roster = await ctx.agentPresets.remoteExportList() - - // The directory still occupies the id, so a surface must be able to show - // and delete it; offering it for selection is what the reason prevents. - expect(roster.presets.find(row => row.id === 'damaged')?.broken).toEqual(expect.any(String)) - }) - - it('answers an empty roster with nothing authorable', async () => { - const ctx = await harness({ default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) - - const roster = await ctx.agentPresets.remoteExportList() - - // Composing no presets is a valid deployment: every session then shares - // the host composition, and nothing can be written either. - expect(roster).toEqual({ presets: [], authorable: false, modeSelectionEnabled: true }) - }) -}) - -describe('reading one composition', () => { - it('rejects an empty id before resolving it', async () => { - const ctx = await harness() - const resolve = vi.spyOn(ctx.agentPresets, 'resolve') - - await expect(ctx.agentPresets.readDocument('')) - .rejects.toMatchObject({ code: 'gateway/bad-request' }) - expect(resolve).not.toHaveBeenCalled() - }) - - it('answers the stored text with the row it belongs to', async () => { - const ctx = await harness() - - const document = await ctx.agentPresets.readDocument('standard') - - // The shipped set is readable: it is the known-good composition a copy - // starts from, and trust is what tells a surface to say so. - expect(document).toEqual({ - agentPreset: 'standard', - trust: 'system', - content: await ctx.agentPresets.read('standard'), - }) - }) - - it('carries the display metadata a preset published', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) - roots.push(userRoot) - await mkdir(join(userRoot, 'documented'), { recursive: true }) - await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID) - await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n') - const ctx = await harness({ - default: 'documented', - roots: [{ path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - const document = await ctx.agentPresets.readDocument('documented') - - // The viewer titles the dialog from the published name, so both optional - // fields have to survive the projection rather than only the id. - expect(document).toEqual({ - agentPreset: 'documented', - trust: 'user', - content: VALID, - name: '我的模式', - description: '只做检索。', - }) - }) - - it('refuses an id no root supplies', async () => { - const ctx = await harness() - - const failure = await remoteFailure(ctx.agentPresets.readDocument('never-existed')) - - expect(failure).toMatchObject({ - code: 'agent-preset/not-found', - details: { - agentPreset: 'never-existed', - }, - }) - expect(failure.message) - .toMatch(/^agent-presets: preset "never-existed" not found \(available: .+\)$/) - expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) - }) - - it('raises an unrelated read failure exactly as it was thrown', async () => { - const ctx = await harness() - const thrown = new Error('disk failed') - vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(thrown) - - await expect(ctx.agentPresets.readDocument('standard')).rejects.toBe(thrown) - }) -}) - -describe('authoring over Remote', () => { - it('rejects empty source, target, and delete ids before authoring', async () => { - const ctx = await harness() - const copy = vi.spyOn(ctx.agentPresets, 'copy') - const remove = vi.spyOn(ctx.agentPresets, 'remove') - - for (const operation of [ - () => ctx.agentPresets.remoteExportCopy('', 'mine'), - () => ctx.agentPresets.remoteExportCopy('standard', ''), - () => ctx.agentPresets.remoteExportDelete(''), - ]) { - await expect(operation()).rejects.toMatchObject({ code: 'gateway/bad-request' }) - } - expect(copy).not.toHaveBeenCalled() - expect(remove).not.toHaveBeenCalled() - }) - - it('copies and deletes through the Remote adapters', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) - roots.push(userRoot) - const ctx = await harness({ - default: 'standard', - roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - await ctx.agentPresets.remoteExportCopy('standard', 'mine', '我的模式') - expect((await ctx.agentPresets.resolve('mine')).name).toBe('我的模式') - - await ctx.agentPresets.remoteExportDelete('mine') - await expect(ctx.agentPresets.resolve('mine')).rejects.toThrow(/not found/) - }) - - it('preserves not-found details for an unknown copy source', async () => { - const ctx = await harness() - - const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('never-existed', 'mine')) - - expect(failure).toMatchObject({ - code: 'agent-preset/not-found', - details: { - agentPreset: 'never-existed', - }, - }) - expect(failure.message) - .toMatch(/^agent-presets: preset "never-existed" not found \(available: .+\)$/) - expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) - }) - - it('preserves invalid-id and occupied-id failures', async () => { - const ctx = await harness() - - const invalid = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', '../escape')) - expect(invalid).toMatchObject({ - code: 'agent-preset/invalid', - details: { agentPreset: '../escape' }, - }) - expect(reasonOf(invalid)).toContain('must match') - - const occupied = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'minimal')) - expect(occupied).toMatchObject({ - code: 'agent-preset/invalid', - details: { agentPreset: 'minimal' }, - }) - expect(reasonOf(occupied)).toContain('already exists') - }) - - it('keeps the requested id when no writable root exists', async () => { - const ctx = await harness({ - default: 'standard', - roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - - const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine')) - - expect(failure).toMatchObject({ - code: 'agent-preset/read-only', - details: { agentPreset: 'mine' }, - }) - expect(reasonOf(failure)).toContain('no user-writable preset root') - }) - - it('preserves read-only and not-found delete failures', async () => { - const ctx = await harness() - - const readOnly = await remoteFailure(ctx.agentPresets.remoteExportDelete('standard')) - expect(readOnly).toMatchObject({ - code: 'agent-preset/read-only', - details: { agentPreset: 'standard' }, - }) - expect(reasonOf(readOnly)).toContain('ships with the deployment') - - const missing = await remoteFailure(ctx.agentPresets.remoteExportDelete('never-existed')) - expect(missing).toMatchObject({ - code: 'agent-preset/not-found', - details: { agentPreset: 'never-existed' }, - }) - expect(availableOf(missing)).toEqual(expect.arrayContaining(['minimal', 'standard'])) - }) - - it('raises an unrelated authoring failure exactly as it was thrown', async () => { - const ctx = await harness() - const thrown = new Error('copy failed') - vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(thrown) - - await expect(ctx.agentPresets.remoteExportCopy('standard', 'mine')).rejects.toBe(thrown) - }) -}) - -describe('switching one session\'s composition', () => { - it('rejects an empty preset id before queuing a switch', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-empty', 'standard') - const recompose = vi.spyOn(ctx.agentPresets, 'recompose') - - await expect(ctx.agentPresets.select(agent, '')) - .rejects.toMatchObject({ code: 'gateway/bad-request' }) - expect(recompose).not.toHaveBeenCalled() - }) - - it('recomposes a blank session and records what it now runs', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-1', 'standard') - - expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - - // The header is written once at creation, so the switch lives in the log: - // that is what a restart replays and what every projection resolves from. - expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('minimal') - expect(recordedPreset(agent)).toEqual({ agentPreset: 'minimal' }) - }) - - it('treats an absent turn boundary as no prior turn', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-no-turn-boundary', 'standard') - const stateOf = ctx.sessionProjections.stateOf.bind(ctx.sessionProjections) - vi.spyOn(ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => ( - key === 'turnBoundary' ? undefined : stateOf(session, key) - )) - - expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - }) - - it('serializes two concurrent switches on one session', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-race', 'standard') - - // Both pass the blank check; unserialized, the second re-link finds the - // record the first already replaced and two compositions end up in one - // agent layer. A client's busy flag is not enforcement. - await Promise.all([ - ctx.agentPresets.select(agent, 'minimal'), - ctx.agentPresets.select(agent, 'standard'), - ]) - - // One winner, and the log agrees with it: the last committed switch. - expect(recordedPreset(agent)).toEqual({ agentPreset: 'standard' }) - expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard') - }) - - it('refuses once the conversation has started', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-locked', 'standard') - // One turn is enough: the history from here on was produced under - // `standard`'s tools, and a swap would strand those tool calls. - agent.session.append('turn/start', { turn: 0 }) - - const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal')) - - expect(failure).toMatchObject({ - code: 'agent-preset/locked', - message: 'session "sel-locked" has already started; its agent preset is fixed', - details: { sessionId: SessionId('sel-locked'), agentPreset: 'minimal' }, - }) - expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard') - expect(recordedPreset(agent)).toBeUndefined() - }) - - it('leaves the session on its composition when the named preset is unknown', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-unknown', 'standard') - - const failure = await remoteFailure(ctx.agentPresets.select(agent, 'nope')) - - expect(failure).toMatchObject({ - code: 'agent-preset/not-found', - details: { agentPreset: 'nope' }, - }) - expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) - // Resolution happens before any re-link, and nothing is recorded until the - // swap commits. - expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard') - expect(recordedPreset(agent)).toBeUndefined() - }) - - it('serves a later switch after one was refused', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-after-failure', 'standard') - - await expect(ctx.agentPresets.select(agent, 'nope')).rejects.toThrow(/not found/) - - // The queue holds a failure-swallowing guard, so a refused switch does not - // reject the next caller's chain. - expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - }) - - it('reports an unusable composition with its discovery reason', async () => { - const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) - roots.push(userRoot) - await mkdir(join(userRoot, 'damaged'), { recursive: true }) - const ctx = await harness({ - default: 'standard', - roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }], - includeShippedRoot: false, - includeUserRoot: false, - }) - const agent = await agentOn(ctx, 'sel-broken', 'standard') - - const failure = await remoteFailure(ctx.agentPresets.select(agent, 'damaged')) - - expect(failure).toMatchObject({ - code: 'agent-preset/invalid', - details: { agentPreset: 'damaged' }, - }) - expect(reasonOf(failure)).not.toBe('') - }) - - it('raises an unrelated switch failure exactly as it was thrown', async () => { - const ctx = await harness() - const agent = await agentOn(ctx, 'sel-internal', 'standard') - const thrown = new Error('mount failed') - vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(thrown) - - await expect(ctx.agentPresets.select(agent, 'minimal')).rejects.toBe(thrown) - }) -}) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts deleted file mode 100644 index 1817113629..0000000000 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * The default preset is a user setting behind the preset picker. While the - * picker is hidden, `config.default` remains the deployment's safe default; - * once shown, the settings document overrides it and is hot-reloaded. - */ - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' -import { afterEach, describe, expect, it } from 'vitest' -import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] -const NS = SETTINGS_NAMESPACE - -/** Every temp root created by this file, removed after each test. */ -const roots: string[] = [] -afterEach(async () => { - for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -/** - * A composition with a real file-backed settings provider. `settingsFiber` is - * the provider's own handle, so a test can take it away the way a reload does. - */ -async function harness( - extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [], -): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { - const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) - roots.push(home) - const settingsFile = join(home, 'settings.yaml') - await writeFile(settingsFile, '{}\n') - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false }) - await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeShippedRoot: false, includeUserRoot: false }) - return { ctx, settingsFile, settingsFiber } -} - -const toolNames = (ctx: Context, agent?: unknown): string[] => - ctx.tools.schemas(agent as never).map(schema => schema.name).sort() - -describe('the default preset as a user setting', () => { - it('shows mode selection on the composition default by default', async () => { - const { ctx } = await harness() - - expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) - expect(ctx.agentPresets.defaultId).toBe('standard') - - await ctx.settings.update(NS, { modeSelectionEnabled: false }) - expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(false) - expect(ctx.agentPresets.defaultId).toBe('standard') - }) - - it('temporarily ignores the saved user default while selection is off', async () => { - const { ctx } = await harness() - - await ctx.settings.update(NS, { default: 'minimal' }) - expect(ctx.agentPresets.defaultId).toBe('minimal') - - await ctx.settings.update(NS, { modeSelectionEnabled: false }) - expect(ctx.agentPresets.defaultId).toBe('standard') - - await ctx.settings.update(NS, { modeSelectionEnabled: true }) - expect(ctx.agentPresets.defaultId).toBe('minimal') - }) - - it('composes a new session from the user default', async () => { - const { ctx } = await harness() - await ctx.settings.update(NS, { default: 'minimal' }) - - const handle = await ctx.agents.create({ - sessionId: SessionId('settings-default'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), - }) - try { - expect(toolNames(ctx, handle.agent)).toEqual(['beta']) - } finally { - await handle.dispose() - } - }) - - it('leaves a running session on the preset it was composed from', async () => { - const { ctx } = await harness() - const running = await ctx.agents.create({ - sessionId: SessionId('settings-running'), - setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), - }) - try { - expect(toolNames(ctx, running.agent)).toEqual(['alpha']) - - // Changing the default mid-flight must not reach an agent that already - // composed: its history was produced under `standard`'s tools. - await ctx.settings.update(NS, { default: 'minimal' }) - - expect(ctx.agentPresets.defaultId).toBe('minimal') - expect(toolNames(ctx, running.agent)).toEqual(['alpha']) - - await ctx.settings.update(NS, { modeSelectionEnabled: false }) - - expect(ctx.agentPresets.defaultId).toBe('standard') - expect(toolNames(ctx, running.agent)).toEqual(['alpha']) - } finally { - await running.dispose() - } - }) - - it('re-inherits the composition default when the user setting is cleared', async () => { - const { ctx } = await harness() - await ctx.settings.update(NS, { default: 'minimal' }) - expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) - expect(ctx.agentPresets.defaultId).toBe('minimal') - - await ctx.settings.replace(NS, {}) - - expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) - expect(ctx.agentPresets.defaultId).toBe('standard') - }) - - it('clears a user default it has just deleted', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-')) - roots.push(root) - await mkdir(join(root, 'mine')) - await writeFile( - join(root, 'mine', COMPOSITION_FILE), - `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`, - ) - const { ctx } = await harness([{ path: root, trust: 'user' as const }]) - await ctx.settings.update(NS, { default: 'mine' }) - expect(ctx.agentPresets.defaultId).toBe('mine') - - await ctx.agentPresets.remove('mine') - - // Nothing will ever supply that id again, so leaving the setting pointed at - // it would fail every session created without an explicit pick. Clearing it - // exposes the deployment's own default underneath. - expect(ctx.agentPresets.defaultId).toBe('standard') - expect((await ctx.agentPresets.resolve()).id).toBe('standard') - }) - - it('reports an unknown user default only when a session tries to use it', async () => { - const { ctx } = await harness() - - // Storing it succeeds — the roster is a live directory, so a name that is - // absent now may exist by the time a session asks for it. - await ctx.settings.update(NS, { default: 'no-such-preset' }) - - await expect(ctx.agentPresets.resolve()) - .rejects.toThrow(/preset "no-such-preset" not found/) - }) -}) - -describe('a settings provider that goes away', () => { - it('falls back to the composition default when the provider unloads', async () => { - const { ctx, settingsFiber } = await harness() - await ctx.settings.update(NS, { default: 'minimal' }) - expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) - expect(ctx.agentPresets.defaultId).toBe('minimal') - - // Unloading the provider takes the user layer with it; the roster keeps - // working on its composition default rather than holding a stale override. - await settingsFiber.dispose() - - expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) - expect(ctx.agentPresets.defaultId).toBe('standard') - }) -}) diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts deleted file mode 100644 index 1882d0d5b7..0000000000 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * The shipped presets are this package's own, not an assembly fact each app - * must patch in: a roster configured with nothing still supplies the built-in - * compositions, prepended so they always mount and win a duplicate id. - * `includeShippedRoot: false` is how a deployment supplying purely its own - * presets — or an embedder using the roster as bare machinery — opts out. - * - * `$DSH_HOME` is repointed per test for the same reason as the user-root - * suite: the derived writable root is resolved in the constructor. - */ - -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import { evaluate, isJsExpr } from '@deepseek-ai/cordis-plugin-loader' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import * as yaml from 'js-yaml' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh-agent-presets' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const SYSTEM_ROOT = join(FIXTURES, 'system') - -let home: string -let previousHome: string | undefined - -beforeEach(async () => { - previousHome = process.env.DSH_HOME - home = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) - process.env.DSH_HOME = home -}) - -afterEach(async () => { - if (previousHome === undefined) delete process.env.DSH_HOME - else process.env.DSH_HOME = previousHome - await rm(home, { recursive: true, force: true }) -}) - -/** Boot a roster with the shipped root left to the plugin's default. */ -async function roster(config: Partial = {}): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentPresets, { - default: 'standard', - roots: [], - includeShippedRoot: true, - includeUserRoot: true, - ...config, - }) - return ctx -} - -interface ShippedEntry { - id?: unknown - disabled?: unknown - config?: unknown -} - -/** Find one entry through the shipped composition's nested groups. */ -function findEntry(entries: unknown[], id: string): ShippedEntry | undefined { - for (const entry of entries) { - if (typeof entry !== 'object' || entry === null) continue - const candidate = entry as ShippedEntry - if (candidate.id === id) return candidate - if (Array.isArray(candidate.config)) { - const nested = findEntry(candidate.config, id) - if (nested !== undefined) return nested - } - } - return undefined -} - -/** Read and validate one shipped preset's Cordis entry list. */ -async function shippedEntries(id: string): Promise { - const source = await readFile(join(SHIPPED_PRESET_ROOT, id, 'agent.cordis.yml'), 'utf8') - const entries: unknown = yaml.load(source, { schema: entryListSchema }) - if (!Array.isArray(entries)) throw new TypeError(`${id} preset must contain a Cordis entry list`) - return entries.map((entry: unknown) => entry) -} - -describe('the shipped preset root', () => { - it('supplies the built-in presets from a bare roster, healthy and system-trusted', async () => { - const ctx = await roster({ includeUserRoot: false }) - - const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard']) - expect(listed.every(preset => preset.trust === 'system')).toBe(true) - // Not `broken === undefined`: health asks whether each row's package is - // installed above the base, and the shipped rows name packages the - // deployment installs beside the roster. This fixture base is not that - // install, so unresolved rows are the only reason it can report here — - // malformed would be a different one, and this asserts there is none. - expect(listed.map(preset => preset.broken) - .filter(reason => reason !== undefined && !reason.includes('cannot be resolved'))).toEqual([]) - }) - - it('prepends the shipped root before configured roots and the derived user root', async () => { - const ctx = await roster({ roots: [{ path: SYSTEM_ROOT, trust: 'user' }] }) - - expect(ctx.agentPresets.roots.map(root => root.path)).toEqual([ - SHIPPED_PRESET_ROOT, - SYSTEM_ROOT, - expect.stringContaining('.agent-presets'), - ]) - expect(ctx.agentPresets.roots[0]).toEqual({ path: SHIPPED_PRESET_ROOT, trust: 'system' }) - // Prepended, so a configured directory claiming a shipped id is shadowed: - // the fixture root also carries `minimal`, and the roster serves the - // shipped one. - const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal') - expect(minimal?.path.startsWith(SHIPPED_PRESET_ROOT)).toBe(true) - }) - - it('mounts a roster without the shipped set when includeShippedRoot is false', async () => { - const ctx = await roster({ - includeShippedRoot: false, - includeUserRoot: false, - roots: [{ path: SYSTEM_ROOT, trust: 'system' }], - }) - - expect(ctx.agentPresets.roots).toEqual([{ path: SYSTEM_ROOT, trust: 'system' }]) - const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal') - expect(minimal?.path.startsWith(SYSTEM_ROOT)).toBe(true) - }) - - it('enables web_fetch in each tool-bearing Web app preset', async () => { - for (const id of ['cordis', 'ptc', 'standard']) { - const entries = await shippedEntries(id) - const toolWeb: unknown = entries.find((entry: unknown) => - typeof entry === 'object' && entry !== null && 'id' in entry && entry.id === 'tool-web') - if (typeof toolWeb !== 'object' || toolWeb === null || !('config' in toolWeb) - || typeof toolWeb.config !== 'object' || toolWeb.config === null || !('fetch' in toolWeb.config)) { - throw new TypeError(`${id} preset must configure tool-web.fetch`) - } - expect(toolWeb.config.fetch, id).toBe(true) - } - }) - - it('omits the general workflow tool and its unused engine only from PTC', async () => { - const ptc = await shippedEntries('ptc') - expect(findEntry(ptc, 'tool-workflow')?.disabled).toBe(true) - expect(findEntry(ptc, 'workflow-ptc')?.disabled).toBe(true) - - for (const id of ['standard', 'cordis']) { - const entries = await shippedEntries(id) - expect(findEntry(entries, 'tool-workflow')?.disabled, id).not.toBe(true) - expect(findEntry(entries, 'workflow-ptc')?.disabled, id).not.toBe(true) - } - }) - - it('enables the cordis plugin-manager tool exactly when a launched profile provides profileContext', async () => { - // Same condition as the host `plugin-manager` row in `dsh-base`: the tool - // injects `pluginManager`, so without that service the row must be - // disabled rather than left waiting and blocking the whole mount. - const disabled = findEntry(await shippedEntries('cordis'), 'tool-plugin-manager')?.disabled - if (!isJsExpr(disabled)) throw new TypeError('cordis tool-plugin-manager must gate on a !!js expression') - const scope = (profileContext: object | undefined) => ({ - ctx: { get: (name: string) => (name === 'profileContext' ? profileContext : undefined) }, - }) - expect(evaluate(scope(undefined), disabled.__jsExpr)).toBe(true) - expect(evaluate(scope({ name: 'web' }), disabled.__jsExpr)).toBe(false) - }) - - it('disables the ralph tool in every shipped preset that carries it', async () => { - for (const id of ['cordis', 'ptc', 'standard']) { - expect(findEntry(await shippedEntries(id), 'tool-ralph')?.disabled, id).toBe(true) - } - expect(findEntry(await shippedEntries('minimal'), 'tool-ralph')).toBeUndefined() - }) -}) diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts deleted file mode 100644 index f8eaf15a6d..0000000000 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * The writable root is this package's own, not an assembly fact each app must - * remember: a roster configured with only a `system` root still discovers and - * authors into `/.agent-presets`, the way `dsh-skill-filesystem` owns - * `/skills`. `includeUserRoot: false` is how a deployment — or a test - * pinning an exact roster — opts out. - * - * `$DSH_HOME` is repointed per test because the derived root is resolved in the - * constructor: the plugin must be mounted while the environment names the - * temporary home, or it would reach the developer's real one. - */ - -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' - -const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') -const SYSTEM_ROOT = join(FIXTURES, 'system') -/** Spelled out rather than imported: the convention is what these tests assert. */ -const USER_ROOT_SEGMENT = '.agent-presets' -const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' - -let home: string -let previousHome: string | undefined - -/** Extra per-test roots, removed with the home directory. */ -const explicitRoots: string[] = [] - -beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) - previousHome = process.env.DSH_HOME - process.env.DSH_HOME = home -}) - -afterEach(async () => { - if (previousHome === undefined) delete process.env.DSH_HOME - else process.env.DSH_HOME = previousHome - await rm(home, { recursive: true, force: true }) - for (const root of explicitRoots.splice(0)) await rm(root, { recursive: true, force: true }) -}) - -/** Boot a roster over the fixture system root, with the derived root left to the plugin. */ -async function roster(config: Partial = {}): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentPresets, { - default: 'standard', - roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], - // The package's shipped presets would shadow this file's fixture ids. - includeShippedRoot: false, - includeUserRoot: true, - ...config, - }) - return ctx -} - -/** Hand-place a preset directory under the harness home's preset root. */ -async function seedHomePreset(id: string): Promise { - await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true }) - await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID) -} - -describe('the harness-home preset root', () => { - it('is what a roster gets when config names no roots at all', () => { - // The schema default is the contract an app relies on by saying nothing; - // every other case here passes the field explicitly. The cast stands for - // the untyped document the Loader hands the schema, which is where a - // composition that omits the key actually comes from. - const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config) - - expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] }) - }) - - it('is discovered without any app configuring it', async () => { - await seedHomePreset('mine') - const ctx = await roster() - - const listed = await ctx.agentPresets.list() - - expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) - expect((await ctx.agentPresets.resolve('mine')).path) - .toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE)) - }) - - it('makes a roster with only a system root authorable, and receives the copy', async () => { - const ctx = await roster() - - expect(ctx.agentPresets.authorable).toBe(true) - await ctx.agentPresets.copy('standard', 'copied') - - expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true) - }) - - it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { - // `standard` exists in the fixture system root; claiming the name at home - // must not take it over, because `copy` refuses an id any root supplies - // and a session resolving `standard` must reach the shipped composition. - await seedHomePreset('standard') - const ctx = await roster() - - expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system') - await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/) - }) - - it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => { - await seedHomePreset('mine') - const ctx = await roster({ includeUserRoot: false }) - - expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine') - expect(ctx.agentPresets.authorable).toBe(false) - await expect(ctx.agentPresets.copy('standard', 'mine')) - .rejects.toThrow(/no user-writable preset root/) - }) - - it('yields to a configured user root for authoring, which writableRoot takes first', async () => { - const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) - explicitRoots.push(explicit) - const ctx = await roster({ - roots: [ - { path: SYSTEM_ROOT, trust: 'system' as const }, - { path: explicit, trust: 'user' as const }, - ], - }) - - await ctx.agentPresets.copy('standard', 'copied') - - expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) - expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false) - }) -}) diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index 5a24f3e62f..20caa0182e 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/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/persona/README.md -README.md: 11f9fdc1d3968fdb9ba0792936a050aca04d4e8b -README.zh.md: 9903c857121123d7ad9f29b8dbf7fb4e9cba4454 +README.md: f5525c5ff77dbd73bfeaf99f3320a4c53690ba50 +README.zh.md: 96f126cd940402ca57e512b5d61200aa0b1e4036 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md index 11f9fdc1d3..f5525c5ff7 100644 --- a/packages/preset/persona/README.md +++ b/packages/preset/persona/README.md @@ -84,7 +84,7 @@ The row registers scoped persona prefix and suffix sections using the registry's Read these pages when the package-level contract is not enough; they move from the preset composition to the prompt registry this row feeds. -- [agent-presets package](../agent-presets/README.md) — the preset composition this row mounts into. +- [agent-presets package](../agent-preset-registry/README.md) — the preset composition this row mounts into. - [System prompt subsystem](../../../docs/subsystems/system-prompt.md) — sections, assembly, and the persona slot this row shadows. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-persona) — every accepted config field and its source declaration. diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index 9903c85712..96f126cd94 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面;它们从 preset 组装逐步进入本行所供给的提示词注册表。 -- [agent-presets 包](../agent-presets/README.zh.md)——本行挂载进的 preset 组装。 +- [agent-presets 包](../agent-preset-registry/README.zh.md)——本行挂载进的 preset 组装。 - [系统提示词子系统](../../../docs/subsystems/system-prompt.zh.md)——段落、组装,以及本行所遮蔽的人设槽位。 - [生成的配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-persona)——每个受支持配置字段及其源声明。 diff --git a/packages/runtime-diagnostics/invariants/README.i18n.yaml b/packages/runtime-diagnostics/invariants/README.i18n.yaml index 203874c50f..a21c30848b 100644 --- a/packages/runtime-diagnostics/invariants/README.i18n.yaml +++ b/packages/runtime-diagnostics/invariants/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/runtime-diagnostics/invariants/README.md -README.md: 3841ca14f2236ddba1976926c13242b6f92bc16f -README.zh.md: 913a72bc1711c025b8a8259c8929dfe957fc0354 +README.md: edb51231463cf68bc0562bd7dc54aa40a9cd4904 +README.zh.md: 1e2216a3b085cac083faddc3618c3d2c409f02bb diff --git a/packages/runtime-diagnostics/invariants/README.md b/packages/runtime-diagnostics/invariants/README.md index 3841ca14f2..edb5123146 100644 --- a/packages/runtime-diagnostics/invariants/README.md +++ b/packages/runtime-diagnostics/invariants/README.md @@ -65,7 +65,7 @@ Each companion protects relationships its package owns, and a companion installs | `dsh-permission-presets`, `dsh-user-approval`, `dsh-commands` | Preset references to live presets, approval asked/decided pairing, command run/done pairing | | `dsh-jobs`, `dsh-tool-todo`, `dsh-time-context` | Job snapshot field relationships, whole-list todo shape, durable clock readings | | `dsh-credentials`, `dsh-settings`, `dsh-storage-domain`, `dsh-workspace` | Commit events against the live service or memory state, entity-cache mirroring | -| `dsh-agent-presets`, `dsh-session-title`, `dsh-plan-mode`, `dsh-schedule` | Preset mount placement, title source citation, plan-mode payload, schedule stream | +| `dsh-agent-preset-registry`, `dsh-session-title`, `dsh-plan-mode`, `dsh-schedule` | Preset mount placement, title source citation, plan-mode payload, schedule stream | | `dsh-client-hmr`, `dsh-client-modules`, `dsh-client-runtime` | Browser/node-half stat-watcher lifecycle, boot entry graph, slot mutation versioning | Every other workspace package omits the companion and states the package-specific reason in its README. diff --git a/packages/runtime-diagnostics/invariants/README.zh.md b/packages/runtime-diagnostics/invariants/README.zh.md index 913a72bc17..1e2216a3b0 100644 --- a/packages/runtime-diagnostics/invariants/README.zh.md +++ b/packages/runtime-diagnostics/invariants/README.zh.md @@ -65,7 +65,7 @@ kind: "package-reference" | `dsh-permission-presets`、`dsh-user-approval`、`dsh-commands` | preset 引用指向活动 preset、审批询问/决定配对、命令运行/完成配对 | | `dsh-jobs`、`dsh-tool-todo`、`dsh-time-context` | 任务快照字段关系、整表 todo 形状、持久时钟读数 | | `dsh-credentials`、`dsh-settings`、`dsh-storage-domain`、`dsh-workspace` | 提交事件对照活动服务或内存状态、实体缓存镜像 | -| `dsh-agent-presets`、`dsh-session-title`、`dsh-plan-mode`、`dsh-schedule` | preset 挂载位置、标题来源引用、plan-mode 载荷、schedule 流 | +| `dsh-agent-preset-registry`、`dsh-session-title`、`dsh-plan-mode`、`dsh-schedule` | preset 挂载位置、标题来源引用、plan-mode 载荷、schedule 流 | | `dsh-client-hmr`、`dsh-client-modules`、`dsh-client-runtime` | 浏览器/node 侧 stat-watcher 生命周期、启动入口图、slot 变更版本化 | 其余工作区包省略配套入口,并在各自 README 中说明包级原因。 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index bdb58687da..ba0fbe1055 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -277,7 +277,7 @@ export class HarnessSdkJsonRpcServer { // 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"). + // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). const handle = await this.ctx.agents.create({ sessionId: brandString(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index a43abea024..21094f1a44 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -42,7 +42,7 @@ "@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-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index 106b9e3eaa..426c10b127 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -16,14 +16,13 @@ import Include from '@deepseek-ai/cordis-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 AgentPresets from '@deepseek-ai/dsh-agent-preset-registry' 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[] = [] @@ -40,7 +39,10 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'coding' }) + for (const [id, tool] of [['coding', 'preset_only'], ['reviewing', 'reviewing_only']] as const) { + await ctx.agentPresets.register({ id, plugins: [{ name: pathToFileURL(join(FIXTURES, 'plugins/preset-tool.js')).href, config: { tool } }] }) + } const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index a435da1208..c95f7069c2 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -62,7 +62,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", @@ -84,7 +84,7 @@ "@deepseek-ai/dsh-settings": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-presets": { + "@deepseek-ai/dsh-agent-preset-registry": { "optional": true }, "@deepseek-ai/dsh-permission-presets": { @@ -121,7 +121,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 1455f5037c..6583839508 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -26,7 +26,7 @@ import type {} from '@deepseek-ai/dsh-permission-presets' // 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 type {} from '@deepseek-ai/dsh-agent-preset-registry' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 8648737494..9def319863 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -39,7 +39,7 @@ "path": "../../interaction/user-approval" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../sandbox/sandbox" diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index 074db36701..f5baa25eaa 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -25,7 +25,7 @@ export const remoteDefaultResponses: RemoteTable = { failures: [], }), // ui-agent-preset hero chip and header label on first mount. - 'agentPresets/list': ok({ presets: [], authorable: false }), + 'agentPresets/list': ok({ presets: [], modeSelectionEnabled: true }), // cordis-client-runner `ClientCordisInspectRegistry.sync` at apply and on `connection/reset`. 'dynamicCordisRunner/syncInspectManifest': ok(null), // ui-cordis inventory at apply and on `connection/reset`. diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index b234b64011..e0280c9599 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: e17228c694ffb8f69ef694695df54a1fa2f9a21a -README.zh.md: 68556683ce45bbceb510a4e2fa9227ece033c492 +README.md: 2ae2fd047e6359428debfe9252dc87146b646596 +README.zh.md: 62bb667af4705aac1373d9d0dda809dbab60e7da diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index e17228c694..2ae2fd047e 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -85,7 +85,7 @@ Read these pages when you need the consumers or the general subprocess capabilit - [Native directory picker](../../host/directory-picker-native/README.md) — the OS chooser commands this runner executes. - [Session Controller](../../api/session-controller/README.md) — resolves Session-relative workspace paths before opening them. -- [Settings Controller](../../api/settings-controller/README.md) — selects settings documents and agent-preset directories. +- [Settings Controller](../../api/settings-controller/README.md) — selects settings documents. - [Subprocess capability](../../subprocess/subprocess/README.md) — the general subprocess seam, of which this package is not a part. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 68556683ce..62bb667af4 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -85,7 +85,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s - [原生目录选择器](../../host/directory-picker-native/README.zh.md)——本运行器执行的 OS 选择器命令。 - [Session Controller](../../api/session-controller/README.zh.md)——打开前解析 Session 相对 workspace 路径。 -- [Settings Controller](../../api/settings-controller/README.zh.md)——选择 settings 文档与 agent-preset 目录。 +- [Settings Controller](../../api/settings-controller/README.zh.md)——选择 settings 文档。 - [子进程能力](../../subprocess/subprocess/README.zh.md)——通用子进程 seam,本包并非其组成部分。 ----- diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 478de81007..c97287e923 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -40,7 +40,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", @@ -54,7 +54,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", diff --git a/packages/webhook/webhook/src/session.ts b/packages/webhook/webhook/src/session.ts index 384832f92a..ecc9b1b577 100644 --- a/packages/webhook/webhook/src/session.ts +++ b/packages/webhook/webhook/src/session.ts @@ -6,7 +6,7 @@ import { isAbsolute } from 'node:path' import { brandString } from '@deepseek-ai/dsh-brand' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { boundContextSummary, createUserMessage, errorChain, type LlmCallConfig } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-permission-presets' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -124,7 +124,8 @@ export async function createWebhookSession( const resolved = resolveRequest(ctx, request) ctx.permissionPresets.resolve(resolved.permissionPreset) const preset = await ctx.agentPresets.resolve(resolved.agentPreset) - await ctx.agentPresets.standingKeyFor(preset.id) + await using presetScope = await ctx.agentPresets.acquireScope(preset.id) + void presetScope signal.throwIfAborted() const workspace = await ctx.workspaceRegistry.create(resolved.workspacePath) diff --git a/packages/webhook/webhook/tests/runtime.spec.ts b/packages/webhook/webhook/tests/runtime.spec.ts index f3c1f997f5..88c49bac19 100644 --- a/packages/webhook/webhook/tests/runtime.spec.ts +++ b/packages/webhook/webhook/tests/runtime.spec.ts @@ -230,7 +230,7 @@ describe('WebhookRuntime', () => { } as never) ctx.provide('agentPresets', { resolve: async (id: string) => ({ id }), - standingKeyFor: async () => ({}), + acquireScope: async () => ({ key: {}, [Symbol.asyncDispose]: async () => {} }), mount: async (_agentCtx: unknown, id: string) => ({ id }), } as never) ctx.provide('workspaceRegistry', { diff --git a/packages/webhook/webhook/tests/session.spec.ts b/packages/webhook/webhook/tests/session.spec.ts index 5177d60747..8dec0361d5 100644 --- a/packages/webhook/webhook/tests/session.spec.ts +++ b/packages/webhook/webhook/tests/session.spec.ts @@ -98,10 +98,10 @@ function harness(options: HarnessOptions = {}): SessionHarness { if (options.failAt === 'preset-resolve') throw new Error('preset resolve failed') return { id: name } }, - async standingKeyFor(name: string) { + async acquireScope(name: string) { calls.push(`standing:${name}`) if (options.failAt === 'standing') throw new Error('standing failed') - return {} + return { key: {}, [Symbol.asyncDispose]: async () => {} } }, async mount(_agentCtx: unknown, name: string) { calls.push(`mount:${name}`) diff --git a/packages/webhook/webhook/tsconfig.json b/packages/webhook/webhook/tsconfig.json index 8a3f35ba70..11b8fb4180 100644 --- a/packages/webhook/webhook/tsconfig.json +++ b/packages/webhook/webhook/tsconfig.json @@ -33,7 +33,7 @@ "path": "../../core/agent-default-model" }, { - "path": "../../preset/agent-presets" + "path": "../../preset/agent-preset-registry" }, { "path": "../../interaction/permission-presets" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaec62559b..62104b7db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@deepseek-ai/dsh-agent-instructions': specifier: workspace:^ version: link:../../packages/context/agent-instructions + '@deepseek-ai/dsh-agent-preset': + specifier: workspace:^ + version: link:../../packages/preset/agent-preset '@deepseek-ai/dsh-agent-tool-presentation': specifier: workspace:^ version: link:../../packages/core/agent-tool-presentation @@ -770,9 +773,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../packages/test-support/agent-loop-testkit - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../packages/preset/agent-presets + version: link:../packages/preset/agent-preset-registry '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../packages/api/session-controller @@ -1046,9 +1049,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1170,9 +1173,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1288,9 +1291,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -2152,9 +2155,18 @@ importers: packages/bundle/web-app: dependencies: - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-instructions': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../context/agent-instructions + '@deepseek-ai/dsh-agent-preset': + specifier: workspace:^ + version: link:../../preset/agent-preset + '@deepseek-ai/dsh-agent-preset-registry': + specifier: workspace:^ + version: link:../../preset/agent-preset-registry + '@deepseek-ai/dsh-agent-tool-presentation': + specifier: workspace:^ + version: link:../../core/agent-tool-presentation '@deepseek-ai/dsh-api-job-controller': specifier: workspace:^ version: link:../../api/job-controller @@ -2332,6 +2344,18 @@ importers: '@deepseek-ai/dsh-cmdline': specifier: workspace:^ version: link:../../boot/cmdline + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../compaction/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-compaction-basic': + specifier: workspace:^ + version: link:../../compaction/compaction-basic + '@deepseek-ai/dsh-compaction-tool-result-pruner': + specifier: workspace:^ + version: link:../../compaction/compaction-tool-result-pruner '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:^ version: link:../../extensions/cordis-client-runner @@ -2374,6 +2398,15 @@ importers: '@deepseek-ai/dsh-office-to-pdf': specifier: workspace:^ version: link:../../document/office-to-pdf + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../preset/persona + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../plan/plan-mode + '@deepseek-ai/dsh-plugin-manager': + specifier: workspace:^ + version: link:../../boot/plugin-manager '@deepseek-ai/dsh-session-log-export': specifier: workspace:^ version: link:../../session-query/session-log-export @@ -2386,18 +2419,78 @@ importers: '@deepseek-ai/dsh-session-turn-outline': specifier: workspace:^ version: link:../../session/session-turn-outline + '@deepseek-ai/dsh-skill-filesystem': + specifier: workspace:^ + version: link:../../skill/skill-filesystem '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../interaction/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../shell/tool-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../shell/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../extensions/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-present': + specifier: workspace:^ + version: link:../../deliverables/tool-present + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../shell/tool-pwsh-persistent + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow '@deepseek-ai/dsh-web-frontend': specifier: workspace:^ version: link:../../../apps/web + '@deepseek-ai/dsh-workflow-ptc': + specifier: workspace:^ + version: link:../../workflow/workflow-ptc '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -2655,9 +2748,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -4310,9 +4403,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -8156,9 +8249,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot @@ -8707,9 +8800,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot @@ -8996,14 +9089,27 @@ importers: specifier: workspace:^ version: link:../../interaction/user-questions - packages/preset/agent-presets: + packages/preset/agent-preset: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent-preset-registry': + specifier: workspace:^ + version: link:../agent-preset-registry + + packages/preset/agent-preset-registry: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - js-yaml: - specifier: ^4.1.0 - version: 4.2.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -9026,21 +9132,30 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-preset': + specifier: workspace:^ + version: link:../agent-preset '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write - '@deepseek-ai/dsh-home-paths': + '@deepseek-ai/dsh-hmr': specifier: workspace:^ - version: link:../../util/home-paths + version: link:../../boot/hmr '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -9065,6 +9180,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval packages/preset/persona: dependencies: @@ -11011,9 +11129,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -11416,9 +11534,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../../fs/fs-sandbox @@ -12507,9 +12625,9 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model - '@deepseek-ai/dsh-agent-presets': + '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:^ - version: link:../../preset/agent-presets + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -12824,6 +12942,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-agent-preset-registry': + specifier: workspace:^ + version: link:../../packages/preset/agent-preset-registry '@deepseek-ai/dsh-agent-tool-presentation': specifier: workspace:^ version: link:../../packages/core/agent-tool-presentation diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 16b4ba9886..716e9cf3e8 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", @@ -24,8 +25,6 @@ "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -46,6 +45,7 @@ "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", @@ -60,7 +60,10 @@ "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-manager": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-ptc-runtime": "workspace:^", + "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", @@ -106,11 +109,11 @@ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", - "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", @@ -134,8 +137,6 @@ "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-ptc": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", - "@deepseek-ai/dsh-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 88ed953dbe..179f002f3c 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -168,8 +168,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-ptc-runtime-node': ['lib/process.js'], // The Host entry starts its sibling Worker by URL rather than a package export. '@deepseek-ai/dsh-experimental-inspector': ['lib/worker.js'], - // The shipped preset compositions travel inside the roster package. - '@deepseek-ai/dsh-agent-presets': ['presets'], + // Creator's composition guidance travels with the declaration package. + '@deepseek-ai/dsh-agent-preset': ['skills'], // The Web Host mounts the default-off settings owner independently of each // Agent-scoped delegation-tool instance. '@deepseek-ai/dsh-tool-subagent': ['lib/model-selection-settings.js'], diff --git a/scripts/cordis-yaml.ts b/scripts/cordis-yaml.ts index e7db335c6a..43f81af22e 100644 --- a/scripts/cordis-yaml.ts +++ b/scripts/cordis-yaml.ts @@ -52,3 +52,27 @@ export function isCordisGroupEntry(value: unknown): value is Record).group === true || (value as Record).name === '@deepseek-ai/cordis-plugin-group') } + +/** Test whether an entry declares an Agent preset with a child plugin list. + * @param value Parsed Loader entry. + * @returns Whether config.plugins contains nested entries owned by a preset. + */ +export function isAgentPresetEntry(value: unknown): value is Record & { config: { id: string; plugins: unknown[] } } { + if (typeof value !== 'object' || value === null) return false + const row = value as Record + if (row.name !== '@deepseek-ai/dsh-agent-preset' || typeof row.config !== 'object' || row.config === null) return false + const config = row.config as Record + return typeof config.id === 'string' && Array.isArray(config.plugins) +} + +/** Read preset definitions nested in ordinary groups and patch insertions. + * @param value Parsed Cordis YAML. + * @returns Declared identities and child configurations in document order. + */ +export function presetDefinitions(value: unknown): { id: string; plugins: unknown[] }[] { + if (Array.isArray(value)) return value.flatMap(presetDefinitions) + if (isAgentPresetEntry(value)) return [value.config] + if (typeof value !== 'object' || value === null) return [] + const row = value as Record + return [...presetDefinitions(row.insert), ...(isCordisGroupEntry(row) ? presetDefinitions(row.config) : [])] +} diff --git a/scripts/dependency-catalog/package-lock.json b/scripts/dependency-catalog/package-lock.json index e40949bf5b..1186d14414 100644 --- a/scripts/dependency-catalog/package-lock.json +++ b/scripts/dependency-catalog/package-lock.json @@ -809,9 +809,9 @@ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2" } }, - "node_modules/@deepseek-ai/dsh-agent-presets": { + "node_modules/@deepseek-ai/dsh-agent-preset-registry": { "version": "0.1.5-rc.2", - "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-presets/-/dsh-agent-presets-0.1.5-rc.2.tgz", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-preset-registry/-/dsh-agent-presets-0.1.5-rc.2.tgz", "integrity": "sha512-h0j7IfuHPYh/oJR7SDxh6pzSgUeaHwaaAIg5X0RNL6Z2oQrq0oUmjtArCVVw7nkgt48DjdBwx4EwJ9IdNrHxug==", "license": "MIT", "dependencies": { @@ -908,7 +908,7 @@ "@deepseek-ai/cordis": "^4.0.2", "@deepseek-ai/dsh-agent": "^0.1.5-rc.2", "@deepseek-ai/dsh-agent-default-model": "^0.1.5-rc.2", - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.2", "@deepseek-ai/dsh-attachment": "^0.1.5-rc.2", "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.2", @@ -958,7 +958,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.2", - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-credentials": "^0.1.5-rc.2", "@deepseek-ai/dsh-native-command": "^0.1.5-rc.2", "@deepseek-ai/dsh-session": "^0.1.5-rc.2", @@ -2196,12 +2196,12 @@ "peerDependencies": { "@deepseek-ai/cordis": "^4.0.2", "@deepseek-ai/cordis-plugin-loader": "^1.0.3", - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-brand": "^0.1.5-rc.2", "@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.2" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-presets": { + "@deepseek-ai/dsh-agent-preset-registry": { "optional": true } } @@ -2511,12 +2511,12 @@ "@deepseek-ai/cordis": "^4.0.2", "@deepseek-ai/cordis-plugin-loader": "^1.0.3", "@deepseek-ai/dsh-agent": "^0.1.5-rc.2", - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "^0.1.5-rc.2", "@deepseek-ai/dsh-session": "^0.1.5-rc.2" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-presets": { + "@deepseek-ai/dsh-agent-preset-registry": { "optional": true } } @@ -3376,7 +3376,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "^4.0.2", "@deepseek-ai/dsh-agent": "^0.1.5-rc.2", - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-attachment": "^0.1.5-rc.2", "@deepseek-ai/dsh-invariants": "^0.1.5-rc.2", "@deepseek-ai/dsh-jobs": "^0.1.5-rc.2", @@ -3396,7 +3396,7 @@ "@deepseek-ai/dsh-util-time": "^0.1.5-rc.2" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-presets": { + "@deepseek-ai/dsh-agent-preset-registry": { "optional": true }, "@deepseek-ai/dsh-jobs": { @@ -4118,7 +4118,7 @@ "integrity": "sha512-Ng7YVDt9txh2BlLmu6B+V677c1bihbh/rq3EOtZK4b0JWtgdNgYb1KPIED8+aK2i+FghrCrIy6X60AyJ45eOvw==", "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.2", "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2", "@deepseek-ai/dsh-api-settings-controller": "^0.1.5-rc.2", @@ -4260,7 +4260,7 @@ "@deepseek-ai/cordis": "^4.0.2", "@deepseek-ai/dsh-agent": "^0.1.5-rc.2", "@deepseek-ai/dsh-agent-default-model": "^0.1.5-rc.2", - "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.2", + "@deepseek-ai/dsh-agent-preset-registry": "^0.1.5-rc.2", "@deepseek-ai/dsh-invariants": "^0.1.5-rc.2", "@deepseek-ai/dsh-llm": "^0.1.5-rc.2", "@deepseek-ai/dsh-permission-presets": "^0.1.5-rc.2", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3600155fcd..39436fa3f4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -805,11 +805,13 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts', InsertTextRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts', AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md', - AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', - AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md', - AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md', - AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md', - PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md', + AgentPreset: 'discovered preset record is owned by packages/preset/agent-preset-registry/README.md', + AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-preset-registry/README.md', + PresetDefinition: 'declarative configuration is owned by packages/preset/agent-preset/README.md', + AsyncDisposable: 'TypeScript explicit resource management interface', + AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-preset-registry/README.md', + AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-preset-registry/README.md', + PresetMetadata: 'preset display text is owned by packages/preset/agent-preset-registry/README.md', BashEnvContributor: 'service-local extension type is owned by packages/shell/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts', CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1c676c29d7..e33aeaffcb 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -487,10 +487,10 @@ const SERVICE_ROLES: ServiceRole[] = [ }, { key: 'agentPresets', - pkg: 'agent-presets', + pkg: 'agent-preset-registry', title: 'Per-session agent composition', mode: 'core', - note: '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.', + note: 'Eagerly mounts YAML-declared preset revisions, binds Agents and cold readers to scoped contributions, and retains retired revisions until their last user releases them.', }, { key: 'commands', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 49f8c13d11..b274bbe4fe 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -312,10 +312,7 @@ "dc2f3b946d1994a23ad82426c87326a6c50a477719f9ae1b4687087522d9571b": 1 }, "packages/api/settings-controller/tests/settings-controller.host.spec.ts": { - "078036e2d4c406a7566edc7e7679a7acf05576e3a54324bca6fc55740f4715ac": 1, - "378e52325f972299c6e61e0393a39b49090f20d350a8030c32ae17b0c2360bbc": 1, - "5855fa729ec1c783472f8a842ed103e826af49a2804fa354965dd0e217802ba1": 1, - "ca53e010b333d3647e44212c1c86176d08215216696a7e6c0396431af06634f1": 1 + "378e52325f972299c6e61e0393a39b49090f20d350a8030c32ae17b0c2360bbc": 1 }, "packages/api/terminal-controller/src/index.ts": { "04bc8a44533574365dfedf563e2356458bcf95f990a00906938c977c1d683356": 2 @@ -464,21 +461,18 @@ }, "packages/client/ui-agent-preset/tests/apply.client.spec.ts": { "5c8c41ba658c0576bea6c34b1226c27e7b0328c03c9eea0d6c920f940c9d8f5b": 1, - "b2fb53c6878d75d54e2d39be30f77cd5e08810261e513fe15f3d687e2313fbd7": 2, + "b2fb53c6878d75d54e2d39be30f77cd5e08810261e513fe15f3d687e2313fbd7": 1, "c2369bc12a9476816900a58b06dd52c2f53bc92658559a076cdd70ae3d15f30b": 9, - "c595e4648d474f3a9538ab1e2140c25255bb4675c6e7bae8043adf9158915b69": 13, + "c595e4648d474f3a9538ab1e2140c25255bb4675c6e7bae8043adf9158915b69": 10, "ddca8a18cd7755688ea48674c48c3c8a6107186815ea91f516424bbed0e1c518": 1 }, "packages/client/ui-agent-preset/tests/components.client.spec.tsx": { "0df95ae2a37a0b3403599b7478d4d59fa2611cb1189b67e8b3fd0f4fcbf500e1": 1, "6c63f71c0aeb1a9a97ec28a3242f77eef021ecfcee48238f17592eb2f1040c09": 1 }, - "packages/client/ui-agent-preset/tests/section-store.client.spec.ts": { - "23e04af2a900d7a905ff7657de842e9833384a2d4c4afba33964477e43d8ba52": 1 - }, "packages/client/ui-agent-preset/tests/settings-store.client.spec.ts": { - "c0e852e6b0e2a8a040b65723f8ab1d650d9cb06c26be7f3a0709ddb9f7525595": 1, - "d6a04d954aef7a8edd403910851015c0ce1597f6f300292b5158a8bf993fc1d8": 1 + "74d9d12b6f146dcd600e84c9fde4d8e9e3d4e66aa485cc598ef4e763188b0eac": 1, + "8dc4dd33a728f8be87bedb987dacefe36155443f0d2e0cc58da9716947d85920": 1 }, "packages/client/ui-approval/tests/ui-approval.client.spec.tsx": { "3e392ff0165a51a6ac716515b99af35ec152f89607cff72080cb9afbcff63f46": 1, @@ -690,9 +684,9 @@ "85f4d0d762126e709ae2178ca656f586dac1ba425eb1d8dd402cb5577d6e8225": 1 }, "packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx": { - "3b5c341b13167d33532f21cc09380de90c5c488a3d3869f91b6381fee5394134": 1, + "0d78ec7c8ca39f70a4033ff4d330f8ef26832bb89932f98d1c87d76b4ce16d00": 1, "79c53da50aa08004e201cebca842267ae2650a543b435a7906960f6492c2398b": 1, - "cce779b3f60ebbadd524376047258bfbcbf362abab0496f65306b0835026dc89": 1 + "a3c1509d552d0102e118f3071a234002a5c83c30ca6923f8a87e9e3733ce0dcf": 1 }, "packages/client/ui-settings-plugins/tests/apply.client.spec.ts": { "3d455f54ba188122f29085e9d462bebbca24c517057052f346229efd175e6e3c": 1 @@ -1587,15 +1581,6 @@ "d2c3345795169400442563b4923671c090a724eacaaae7f0c190297b967c1c4d": 1, "dfa30a521aae411e70eee81fc4c0511178589d0656dea01c1c97131bfe097793": 1 }, - "packages/preset/agent-presets/tests/composition-inventory.spec.ts": { - "d605b1b21956ba1f53673edb27c22a40631b6d1af18286ae1d0cf81c5ce1ea8b": 1 - }, - "packages/preset/agent-presets/tests/mount.spec.ts": { - "92cb9ce1fc5e337381501a17571425bff9c50e31b92c9a8c2460976b652d6283": 3 - }, - "packages/preset/agent-presets/tests/user-root.spec.ts": { - "4b091634b70445a68ff23a94065070b1c0b75a68712b1813bf417120a174422d": 1 - }, "packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts": { "aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1, "be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1 diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index dd281965df..5d66a71f74 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -82,12 +82,11 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'vendor/loader/src/config/diff.ts', upstream: ['schemastery'] }, // Asserts the vendored-manifest table, which gains an upstream-name column. { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, - // `cordis` is also an agent-preset id — the directory name under - // packages/preset/agent-presets/presets/ — so in these files the bare name is + // `cordis` is also an agent-preset id, so in these files the bare name is // product data, not a package reference. Renaming it changed which preset // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, - { file: 'packages/preset/agent-presets/tests/shipped-root.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', upstream: ['cordis'] }, @@ -95,11 +94,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, { file: 'apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs', upstream: ['cordis'] }, { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, - { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, - // The preset's own composition: its header comment and its system prompt name - // the preset a model mounts, so the scoped name would send the model after an - // id no roster reports. - { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + { file: 'packages/preset/agent-preset-registry/tests/session.spec.ts', upstream: ['cordis'] }, // The preset-roster loop names the `cordis` preset id, not a package. { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] }, // GROUP_ORDER holds `packages//` directory names, not package names. @@ -164,8 +159,6 @@ const POSTCONDITIONS: readonly PostCondition[] = [ // The preset ids in this table are product data, not package names. { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, // The preset id the shipped composition documents to its own model. - { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, - { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, ] /** diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index 94fabbf21a..79983dcac3 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -14,6 +14,7 @@ const SHIPPED_CONFIG_GLOBS = [ 'apps/*/config/**/*.yml', // Bundle identity comes from the package manifest, not the domain directory. 'packages/*/*/cordis.patch.yml', + 'packages/*/*/presets/*.patch.yml', // The Python runtime ships its own default composition inside the wheel. 'python/*/src/**/cordis.yml', ] diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index d73bce3c22..76e9bdba87 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -15,9 +15,9 @@ import { dirname, relative, resolve } from 'node:path' import { Script } from 'node:vm' import ts from 'typescript' import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts' -import { bundlePatchPaths } from '../packages/boot/app-boot/src/profile.ts' +import { bundlePatchFiles, bundlePatchPaths } from '../packages/boot/app-boot/src/profile.ts' import { cordisConfigFiles } from './cordis-config-files.ts' -import { isCordisGroupEntry, isJsExpr, loadCordisYaml } from './cordis-yaml.ts' +import { isAgentPresetEntry, presetDefinitions, isCordisGroupEntry, isJsExpr, loadCordisYaml } from './cordis-yaml.ts' export interface PackageManifest { name?: string @@ -138,25 +138,28 @@ function validateClientHalvesDeclared(): string[] { */ function validatePresetPlaneSeparation(): string[] { const problems: string[] = [] - // The shipped Web surface is two bundle patch layers over an empty root. + // The shipped Web surface is two bundle layers over an empty root; the web + // layer is its host patch followed by one patch file per shipped preset. const hostFile = 'packages/bundle/base/cordis.patch.yml' - const overlayFile = 'packages/bundle/web-app/cordis.patch.yml' - const hostRows = rowIds(hostFile) - const overlay = loadEntries(overlayFile) + const overlayDir = 'packages/bundle/web-app' + const overlayManifest = JSON.parse(readFileSync(resolve(root, overlayDir, 'package.json'), 'utf8')) as { dsh: { bundle: DshBundleManifest } } + const overlayFiles = bundlePatchFiles(overlayManifest.dsh.bundle).map(file => `${overlayDir}/${file.replace(/^\.\//, '')}`) const disabled = new Set() - for (const entry of overlay) { - if (!isRecord(entry)) continue - if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id) + const overlayRows = new Set() + for (const file of overlayFiles) { + for (const entry of loadEntries(file)) { + if (!isRecord(entry)) continue + if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id) + } + for (const id of rowIds(file)) overlayRows.add(id) } // The overlay's own inserts are host-plane too; its disables take them back out. - const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id))) - for (const file of globSync('packages/preset/agent-presets/presets/*/agent.cordis.yml', { cwd: root })) { - for (const id of rowIds(file)) { - if (!active.has(id)) continue - problems.push( - `${file}: row "${id}" is also active in the host composition; ` - + 'a row belongs to exactly one plane', - ) + const active = new Set([...rowIds(hostFile), ...overlayRows].filter(id => !disabled.has(id))) + for (const file of overlayFiles) { + for (const definition of presetDefinitions(loadEntries(file))) { + for (const id of collectRowIds(definition.plugins)) { + if (active.has(id)) problems.push(`${file}#${definition.id}: row "${id}" is also active in the host composition; a row belongs to exactly one plane`) + } } } return problems @@ -175,6 +178,10 @@ function loadEntries(file: string): unknown[] { * @returns the declared ids. */ function rowIds(file: string): Set { + return collectRowIds(loadEntries(file)) +} + +function collectRowIds(rows: unknown): Set { const ids = new Set() const walk = (value: unknown): void => { if (isUnknownArray(value)) { @@ -183,9 +190,10 @@ function rowIds(file: string): Set { } if (!isRecord(value)) return if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id) - for (const child of Object.values(value)) walk(child) + walk(value.insert) + if (isCordisGroupEntry(value)) walk(value.config) } - walk(loadEntries(file)) + walk(rows) return ids } @@ -201,6 +209,9 @@ function validateEntry(value: unknown, file: string, path: string): void { validateEntry(value.config[index], file, `${path}.config[${index}]`) } } + if (isAgentPresetEntry(value)) { + for (let index = 0; index < value.config.plugins.length; index++) validateEntry(value.config.plugins[index], file, `${path}.config.plugins[${index}]`) + } if (isUnknownArray(value.insert)) { for (let index = 0; index < value.insert.length; index++) { validateEntry(value.insert[index], file, `${path}.insert[${index}]`) diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index f33f5c00ba..f3ac184ae1 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -12,7 +12,7 @@ const experimental = '@deepseek-ai/dsh-experimental-prototype' const core = '@deepseek-ai/dsh-core' const base = '@deepseek-ai/dsh-base' const profile = 'packages/boot/app-boot/src/profile.ts' -const preset = 'packages/preset/agent-presets/presets/standard/agent.cordis.yml' +const preset = 'packages/bundle/web-app/presets/standard.patch.yml' const patch = 'packages/bundle/base/cordis.patch.yml' function write(root: string, path: string, value: unknown): void { @@ -39,7 +39,8 @@ function fixture(): string { write(root, 'packages/core/core/src/index.ts', 'export {}\n') write(root, 'packages/bundle/base/package.json', { name: base, dsh: { bundle: { patch: './cordis.patch.yml' } } }) write(root, patch, [{ insert: [{ name: core }] }]) - write(root, preset, [{ name: core }]) + write(root, preset, [{ insert: [{ name: '@deepseek-ai/dsh-agent-preset', config: { id: 'standard', plugins: [{ name: core }] } }] }]) + write(root, 'packages/preset/agent-preset/package.json', { name: '@deepseek-ai/dsh-agent-preset' }) write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`) write(root, 'packages/experimental/prototype/package.json', { name: experimental }) @@ -79,7 +80,7 @@ describe('default product isolation', () => { write(root, 'apps/web/src/preview.ts', `import '${experimental}'\n`) write(root, 'packages/experimental/prototype/cordis.patch.yml', [{ name: experimental }]) - expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 5, configCount: 2 }) + expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 6, configCount: 2 }) }) it('ignores dependency trees and directories whose names end in a source extension', () => { @@ -101,7 +102,7 @@ describe('default product isolation', () => { write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n` + `export const OPTIONAL_BUNDLES = ['${layer}']\n`) - expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 5 }) + expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 6 }) // The exception covers the dependency edge alone: a runtime import or a default template still names the product. write(root, 'apps/cli/src/bin.ts', `import '${layer}'\n`) diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index 2ce4322e14..1fe49fb2b8 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -14,7 +14,7 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts' import { bundlePatchPaths, composeEntries } from '../packages/boot/app-boot/src/profile.ts' import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts' -import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts' +import { isAgentPresetEntry, isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts' import { collectRuntimeLocalSourceSpecifiers, collectRuntimeSourceSpecifiers, @@ -24,7 +24,7 @@ const EXPERIMENTAL_PREFIX = '@deepseek-ai/dsh-experimental-' // The independently published entry package owns platform-engine dependencies. const EXTERNAL_KIT_PACKAGES = new Set(['@deepseek-ai/libreoffice-kit']) const PROFILE_SOURCE = 'packages/boot/app-boot/src/profile.ts' -const PRESET_PATTERN = 'packages/preset/agent-presets/presets/*/agent.cordis.yml' +const PRESET_PATTERN = 'packages/bundle/web-app/presets/*.patch.yml' const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const interface Manifest { @@ -186,6 +186,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes if (isCordisGroupEntry(entry) || entry.name === 'cordis:group' && Array.isArray(entry.config)) { (entry.config as unknown[]).forEach(visit) } + if (isAgentPresetEntry(entry)) entry.config.plugins.forEach(visit) if (Array.isArray(entry.insert)) entry.insert.forEach(visit) if ((entry.name === '@deepseek-ai/cordis-plugin-include' || entry.name === 'cordis:include') && isRecord(entry.config)) { if (!composedWeb && Array.isArray(entry.config.patches)) entry.config.patches.forEach(visit) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 5044b71f8b..55efc40035 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,7 +80,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, - 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, + 'packages/preset/agent-preset': { kind: 'indirect', reason: 'The declaration selects plugins that own model-visible contributions.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/util/http-proxy': { kind: 'none', reason: 'Transport policy only: it changes how bytes reach the network and registers no prompt, schema, or result text.' }, diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 6d1e8e3edb..942cb2dfeb 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { loadCordisYaml } from './cordis-yaml.ts' import { verifyRuntimeClosure } from './verify-runtime-closure.ts' const roots: string[] = [] @@ -10,9 +11,10 @@ function fixture(files: Record>): strin const root = mkdtempSync(join(tmpdir(), 'dsh-runtime-closure-')) roots.push(root) for (const [relative, value] of Object.entries(files)) { - const path = join(root, relative) + const preset = /^preset:(.+)$/.exec(relative) + const path = join(root, preset === null ? relative : `packages/bundle/web-app/presets/${preset[1]}.patch.yml`) mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`) + writeFileSync(path, preset === null ? (typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`) : JSON.stringify([{ insert: [{ name: '@deepseek-ai/dsh-agent-preset', config: { id: preset[1], plugins: typeof value === 'string' ? loadCordisYaml(value) : value } }] }])) } return root } @@ -41,7 +43,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` + 'preset:standard': ` - id: tools name: cordis:group group: true @@ -74,7 +76,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, 'python/sdk-runtime/platforms.json': platforms, - 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` + 'preset:standard': ` - id: conditional name: '@scope/conditional' disabled: !!js process.env.DSH_DISABLE_CONDITIONAL === '1' @@ -92,7 +94,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` + 'preset:standard': ` - id: plugin name: '@scope/plugin' config: @@ -109,7 +111,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': '1.2.3' } }, 'python/sdk-runtime/platforms.json': platforms, - 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` + 'preset:standard': ` - id: plugin name: '@scope/plugin' `, @@ -132,7 +134,7 @@ describe('verifyRuntimeClosure', () => { expect(result.presetCount).toBe(0) expect(result.failures).toEqual([ - 'no agent presets matched packages/preset/agent-presets/presets/*/agent.cordis.yml', + 'no agent presets matched packages/bundle/web-app/presets/*.patch.yml', ]) }) @@ -140,7 +142,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, 'python/sdk-runtime/platforms.json': {}, - 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': '[]\n', + 'preset:standard': '[]\n', }) const result = await verifyRuntimeClosure(root) @@ -154,7 +156,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'packages/preset/agent-presets/presets/minimal/agent.cordis.yml': '[]\n', + 'preset:minimal': '[]\n', }) workspace(root, '@scope/root', { peerDependencies: { '@scope/required': 'workspace:^', '@scope/optional': 'workspace:^' }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index b0dac1454b..a9109584be 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -6,9 +6,9 @@ */ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { basename, dirname, resolve } from 'node:path' +import { resolve } from 'node:path' import { parseArgs } from 'node:util' -import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts' +import { isCordisGroupEntry, loadCordisYaml, presetDefinitions } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -30,7 +30,7 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const AGENT_PRESET_GLOB = 'packages/preset/agent-presets/presets/*/agent.cordis.yml' +const AGENT_PRESET_GLOB = 'packages/bundle/web-app/presets/*.patch.yml' export interface RuntimeClosureResult { failures: string[] @@ -93,7 +93,7 @@ export async function verifyRuntimeClosure( return { failures, - presetCount: presetPaths.length, + presetCount: (await Promise.all(presetPaths.map(async path => presetDefinitions(loadCordisYaml(await readFile(resolve(root, path), 'utf8'))).length))).reduce((a, b) => a + b, 0), workspacePackageCount: queue.length, } } @@ -130,19 +130,21 @@ async function missingPresetPlugins( failures.push(`${presetPath}: preset root must be a Loader entry array`) continue } - for (const target of targets) { - const processPlatform = processPlatformForTarget(target) - for (const plugin of activeBarePluginPackages(document, processPlatform)) { - const version = runtimeDependencies[plugin] - if (version?.startsWith('workspace:') === true) continue - const preset = basename(dirname(presetPath)) - const declaration = version === undefined - ? '' - : ` [runtime dependency is ${JSON.stringify(version)}; expected workspace:]` - const key = `${preset} preset -> ${plugin}${declaration}` - const targets = missing.get(key) ?? new Set() - targets.add(target) - missing.set(key, targets) + for (const definition of presetDefinitions(document)) { + for (const target of targets) { + const processPlatform = processPlatformForTarget(target) + for (const plugin of activeBarePluginPackages(definition.plugins, processPlatform)) { + const version = runtimeDependencies[plugin] + if (version?.startsWith('workspace:') === true) continue + const preset = definition.id + const declaration = version === undefined + ? '' + : ` [runtime dependency is ${JSON.stringify(version)}; expected workspace:]` + const key = `${preset} preset -> ${plugin}${declaration}` + const targets = missing.get(key) ?? new Set() + targets.add(target) + missing.set(key, targets) + } } } } diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 2389beae9f..3832a4fd0d 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -60,7 +60,7 @@ const dshBin = join(repoRoot, 'apps/cli/src/bin.ts') const tsconfigPath = join(repoRoot, 'tsconfig.json') const editingCordisSkill = join( repoRoot, - 'packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md', + 'packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md', ) type SnapshotMode = 'replay' | 'record' | 'refresh' diff --git a/snapshots/session/skill-load/session.v3.jsonl b/snapshots/session/skill-load/session.v3.jsonl index 47fc6a5ad3..50ae577c38 100644 --- a/snapshots/session/skill-load/session.v3.jsonl +++ b/snapshots/session/skill-load/session.v3.jsonl @@ -9,13 +9,13 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `editing-cordis-compositions`: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"editing-cordis-compositions","description":"Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `editing-cordis-compositions`: Use when creating or editing an Agent preset, choosing Host versus preset plugin placement, or diagnosing preset activation.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"editing-cordis-compositions","description":"Use when creating or editing an Agent preset, choosing Host versus preset plugin placement, or diagnosing preset activation."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Load the editing-cordis-compositions ski","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"editing-cordis-compositions\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile.\n\n## Authoring a preset\n\nUse shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods.\n\nCopy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source.\n\nEdit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance.\n\n## Service isolation\n\nA preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm.\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflowEngine: true\n config:\n - id: workflow-ptc\n name: '@deepseek-ai/dsh-workflow-ptc'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n## Verify a preset\n\nCheck the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nAgent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations. Each configuration has an `id`, optional display metadata, and a `plugins` list of Cordis entries. The `agent_preset` tool and Web editor read and save only this child list. They persist edits in the active profile's `cordis.patch.yml`; presets have no separate directory or file format.\n\n## Edit a preset\n\n1. Call `agent_preset` with `action: list` to discover identities and activation diagnostics.\n2. Call it with `action: read` and the selected `id`. Retain the returned revision.\n3. Modify the returned YAML plugin list. Call `action: save` with `id`, `content`, and `revision`. For a new preset, use a new lowercase identifier, optional `name`, and an empty revision.\n4. Check both persistence and activation. `saved: true` with `broken` means the configuration was saved but new sessions cannot select it. Repair the declaration; do not report it as available.\n\nSaving requires Full access or approval because plugin configuration can execute code in the Host process. A revision conflict requires another read; never overwrite a newer edit blindly. Existing sessions and their children retain their current plugin revision. Validate changed behavior in a new session.\n\n## Choose plugin placement\n\nHost plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents.\n\nA preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances.\n\n```yaml\n- id: persona\n name: '@deepseek-ai/dsh-persona'\n config:\n prefix: You review software changes.\n- id: files\n name: '@deepseek-ai/dsh-tool-fs'\n```\n\nUse ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory.\n\n## Extend the Host\n\nFor new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion.\n\n"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/skill-load/session.v4.jsonl b/snapshots/session/skill-load/session.v4.jsonl index 62c83cec58..9f4ec0a005 100644 --- a/snapshots/session/skill-load/session.v4.jsonl +++ b/snapshots/session/skill-load/session.v4.jsonl @@ -15,7 +15,7 @@ {"type":"session/title","data":{"title":"Load the editing-cordis-compositions ski","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"editing-cordis-compositions\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile.\n\n## Authoring a preset\n\nUse shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods.\n\nCopy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source.\n\nEdit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance.\n\n## Service isolation\n\nA preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm.\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflowEngine: true\n config:\n - id: workflow-ptc\n name: '@deepseek-ai/dsh-workflow-ptc'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n## Verify a preset\n\nCheck the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nAgent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file is the complete contract; do not read DSH package sources to infer formats.\n\n## Where declarations live\n\nThe shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, that bundle sits at `$DSH_HOME/profiles//node_modules/@deepseek-ai/dsh-web-app/`; in a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with `cat` when you need a template; `minimal.patch.yml` is the shortest.\n\nA declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention.\n\n## Create a preset\n\nWrite a bundle directory in the workspace with exactly two files, then install it.\n\n`review-preset/package.json`:\n\n```json\n{\n \"name\": \"@local/dsh-review-preset\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } }\n}\n```\n\n`review-preset/cordis.patch.yml`:\n\n```yaml\n- insert:\n - id: preset-review\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: review\n name: Review\n description: Reviews changes with the shell only.\n order: 10\n plugins:\n - id: persona\n name: '@deepseek-ai/dsh-persona'\n config:\n prefix: You review software changes.\n - id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n```\n\nInstall with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands.\n\n## Change a shipped preset\n\nOverride the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries:\n\n```yaml\n- id: preset-standard\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: standard\n order: 1\n plugins:\n # the shipped list with your changes\n```\n\n## Verify\n\n`plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval.\n\n## Choose plugin placement\n\nHost plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents.\n\nA preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory.\n\n## Extend the Host\n\nFor new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 816b680d6e..f57d8ffb88 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -12,7 +12,7 @@ - button "Context injection skill-catalog" - button "Think" - button "Skill editing-cordis-compositions" [expanded] -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile. ## Authoring a preset Use shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods. Copy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source. Edit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance. ## Service isolation A preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm. ```yaml - id: delegation name: cordis:group group: true isolate: workflowEngine: true config: - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` ## Verify a preset Check the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place: a preset is created or changed by installing a bundle whose patch declares or overrides it. This file is the complete contract; do not read DSH package sources to infer formats. ## Where declarations live The shipped Web presets are `presets/.patch.yml` files of the `@deepseek-ai/dsh-web-app` bundle, ids `standard`, `ptc`, `minimal` and `cordis`. Installed, that bundle sits at `$DSH_HOME/profiles//node_modules/@deepseek-ai/dsh-web-app/`; in a source checkout of DSH it is `packages/bundle/web-app/`. Read one file with `cat` when you need a template; `minimal.patch.yml` is the shortest. A declaration row has these `config` fields: `id` (required, lowercase letters, digits and hyphens), `plugins` (required Cordis entry list), and optional `name`, `description` and `order` (roster position). The Loader row `id` is `preset-` by convention. ## Create a preset Write a bundle directory in the workspace with exactly two files, then install it. `review-preset/package.json`: ```json { \"name\": \"@local/dsh-review-preset\", \"version\": \"1.0.0\", \"private\": true, \"type\": \"module\", \"dsh\": { \"bundle\": { \"patch\": \"./cordis.patch.yml\" } } } ``` `review-preset/cordis.patch.yml`: ```yaml - insert: - id: preset-review name: '@deepseek-ai/dsh-agent-preset' config: id: review name: Review description: Reviews changes with the shell only. order: 10 plugins: - id: persona name: '@deepseek-ai/dsh-persona' config: prefix: You review software changes. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' ``` Install with `plugin_manager`, `action: install_bundle`, `target` set to the absolute bundle directory. It runs package installation and bundle selection itself; do not reproduce those steps with shell commands. ## Change a shipped preset Override the declaration by its Loader row id instead of inserting. The override replaces the complete `config`, so restate `id`, `plugins` and every other field the shipped file carries: ```yaml - id: preset-standard name: '@deepseek-ai/dsh-agent-preset' config: id: standard order: 1 plugins: # the shipped list with your changes ``` ## Verify `plugin_manager` `list_bundles` lists the installed bundle; `list_plugins` shows the `preset-` row with its activation state. A declaration whose activation fails stays on the roster with its diagnostic and cannot compose a session until the bundle is fixed and reinstalled. Existing sessions and their children keep the plugin revision they started with; validate changed behavior in a new session. Installing a bundle executes plugin code in the Host process, so it requires Full access or approval. ## Choose plugin placement Host plugins supply shared services: tools and prompt registries, the Agent loop, sessions, persistence, settings, sandbox policy, model routes, and subagent backends. Preset plugins contribute scoped tools, persona, prompt sections, and policies to those registries. Preset revisions are eagerly activated once and shared by their selecting Agents. A preset plugin that supplies a service must isolate the provider and all its consumers in the same realm. A service consumed by Host plugins belongs in the Host configuration. Scope controls contributions and event visibility; `isolate` controls service instances. Use ordinary Cordis groups for nested plugin lists. Keep `!!js` expressions only in plugin configuration or `disabled`; the Loader evaluates them when activating the child plugin. Resolve assets from installed packages rather than a preset directory. ## Extend the Host For new plugin code or profile-wide capabilities, load `cordis-plugin-development`, author a workspace bundle, and install it with `plugin_manager`. Inspect available APIs through `cordis_inspect_list` and `cordis_inspect_query`; those tools do not invoke Remote methods. Verify the installed capability before reporting completion. " - button "Inspect" - button "Think" - paragraph: DONE diff --git a/tsconfig.base.json b/tsconfig.base.json index 6d331689a3..7f3b436ca2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -120,8 +120,9 @@ "@deepseek-ai/dsh-token-meter/client": ["./packages/llm/token-meter/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], - "@deepseek-ai/dsh-agent-presets/types": ["./packages/preset/agent-presets/src/types.ts"], - "@deepseek-ai/dsh-agent-presets/display": ["./packages/preset/agent-presets/src/display.ts"], + "@deepseek-ai/dsh-agent-preset-registry/tools": ["./packages/preset/agent-preset-registry/src/tools.ts"], + "@deepseek-ai/dsh-agent-preset-registry/types": ["./packages/preset/agent-preset-registry/src/types.ts"], + "@deepseek-ai/dsh-agent-preset-registry/display": ["./packages/preset/agent-preset-registry/src/display.ts"], "@deepseek-ai/dsh-pwsh-local": ["./packages/shell/pwsh-local/src/index.ts"], "@deepseek-ai/dsh-tool-pwsh": ["./packages/shell/tool-pwsh/src/index.ts"], "@deepseek-ai/dsh-shell-env": ["./packages/shell/shell-env/src/index.ts"], @@ -317,8 +318,9 @@ "@deepseek-ai/dsh-agent-instructions": ["./packages/context/agent-instructions/src"], "@deepseek-ai/dsh-agent-loop": ["./packages/core/agent-loop/src"], "@deepseek-ai/dsh-agent-loop-testkit": ["./packages/test-support/agent-loop-testkit/src"], - "@deepseek-ai/dsh-agent-presets": ["./packages/preset/agent-presets/src"], - "@deepseek-ai/dsh-agent-presets/invariant": ["./packages/preset/agent-presets/src/invariant.ts"], + "@deepseek-ai/dsh-agent-preset": ["./packages/preset/agent-preset/src"], + "@deepseek-ai/dsh-agent-preset-registry": ["./packages/preset/agent-preset-registry/src"], + "@deepseek-ai/dsh-agent-preset-registry/invariant": ["./packages/preset/agent-preset-registry/src/invariant.ts"], "@deepseek-ai/dsh-agent-tool-presentation": ["./packages/core/agent-tool-presentation/src"], "@deepseek-ai/dsh-anonymous-user-id": ["./packages/identity/anonymous-user-id/src"], "@deepseek-ai/dsh-app-boot": ["./packages/boot/app-boot/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 0dd50f4d02..50c576de65 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -400,7 +400,8 @@ { "path": "./packages/webhook/webhook-github" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, - { "path": "./packages/preset/agent-presets" }, + { "path": "./packages/preset/agent-preset-registry" }, + { "path": "./packages/preset/agent-preset" }, { "path": "./packages/preset/persona" }, { "path": "./packages/guard/repeat-tool-reminder" }, { "path": "./packages/extensions/cordis-host-runner" },