Procházet zdrojové kódy

fix(cli): patch the agent-preset roots for every dsh launcher

The roots are an assembly fact (the shipped set beside this app's config,
the user's own under $DSH_HOME) but only `dsh web` patched them in, so the
merged `dsh run` booted the roster with no roots and failed resolving
`standard`. The shared profile boot now owns the patch for every launcher,
and master's interrupt_agent tool joins the standard composition's exact
catalog. The roster helpers the wire layer added (standingKeyFor,
serviceForAgent's chain guards, the direct mountPreset boundary) gain the
unit coverage the per-file gate requires.
Yichen Jiang před 2 měsíci
rodič
revize
921fcc1341

+ 25 - 0
apps/cli/src/profile-boot.ts

@@ -12,6 +12,7 @@ import { join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Context } from 'cordis'
 import type { PatchOptions } from '@cordisjs/plugin-include'
+import { dshHomePath } from '@deepseek-ai/dsh-paths'
 import {
   boot,
   composeEntries,
@@ -25,6 +26,12 @@ import {
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+
+/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
+const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
+
+/** Harness-home directory holding locally authored agent presets. */
+const USER_PRESET_DIR = '.agent-presets'
 import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
 import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
 import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
@@ -147,6 +154,24 @@ function composeProfile(
     if (typeof row.id === 'string') rows.set(row.id, row)
   }
   const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
+  // The agent-preset roots are an assembly fact of every dsh launcher, not a
+  // patch author's choice: the shipped set sits beside this app's config and
+  // the user's own under the Harness home. Resolved per boot ($DSH_HOME may
+  // differ per run) and only patched when the composed tree actually mounts
+  // the roster — a one-shot `dsh run` composes agents from the same roster
+  // `dsh web` offers.
+  if (rows.has('agent-presets')) {
+    overlayAndFlags.push({
+      id: 'agent-presets',
+      config: {
+        ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
+        roots: [
+          { path: SHIPPED_PRESET_ROOT, trust: 'system' },
+          { path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
+        ],
+      },
+    })
+  }
   const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
   if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
   return { profile, bundlePatches, homePatches, overlayAndFlags, rows }

+ 3 - 17
apps/cli/src/web.ts

@@ -10,7 +10,6 @@
 
 import { networkInterfaces } from 'node:os'
 import { fileURLToPath } from 'node:url'
-import { dshHomePath } from '@deepseek-ai/dsh-paths'
 import type { Context } from 'cordis'
 import type { PatchOptions } from '@cordisjs/plugin-include'
 import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
@@ -19,12 +18,6 @@ import { runProfile, type ProfileRows } from './profile-boot.ts'
 
 const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
 
-/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
-const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
-
-/** Harness-home directory holding locally authored agent presets. */
-const USER_PRESET_DIR = '.agent-presets'
-
 /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
 const ALL_INTERFACES_HOST = '0.0.0.0'
 
@@ -104,16 +97,9 @@ function deriveWebFlagPatches(
   // inserts the client-hmr row), never pass-throughs of composed values.
   put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
   put('web-runtime', 'lanAddresses', lanAddresses)
-  // The agent-preset roots are an assembly fact, like the values above: the
-  // shipped set sits beside this app's config and the user's own under the
-  // Harness home, and neither location is something a patch author chooses.
-  // Only patched when the composed tree actually mounts the roster.
-  if (rows.has('agent-presets')) {
-    put('agent-presets', 'roots', [
-      { path: SHIPPED_PRESET_ROOT, trust: 'system' },
-      { path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
-    ])
-  }
+  // The agent-preset roots are patched by the shared profile boot: they are
+  // an assembly fact of every dsh launcher, and `dsh run` composes agents
+  // from the same roster this alias offers.
   const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
     const composed = rows.get(id)
     if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)

+ 1 - 1
apps/cli/tests/web-agent-presets.e2e.ts

@@ -115,7 +115,7 @@ describe('the shipped Web composition', () => {
       // depend on ripgrep being present on the machine.
       expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
         'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
-        'get_goal', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
+        'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
         'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
         'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
         'workflow', 'write',

+ 38 - 1
packages/preset/agent-presets/tests/mount.spec.ts

@@ -12,7 +12,10 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { beforeEach, describe, expect, it } from 'vitest'
-import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
+import AgentPresets, {
+  COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
+} from '@deepseek-ai/dsh-agent-presets'
+import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
 
 declare module 'cordis' {
   interface Context {
@@ -182,11 +185,45 @@ describe('rejecting a composition that cannot be used', () => {
   })
 
   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' })
+    setScopeParent(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('hands a host reader the standing key without starting an agent', async () => {
+    const key = await ctx.agentPresets.standingKeyFor('minimal')
+
+    // The mount exists for the reader; no agent, session, or turn started.
+    expect(key).toEqual({ agentPreset: 'minimal' })
+    expect(ctx.agents.get(SessionId('minimal'))).toBeUndefined()
+    // A second reader resolves the same generation, not a new mount.
+    expect(await ctx.agentPresets.standingKeyFor('minimal')).toBe(key)
+  })
+
   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/)