Quellcode durchsuchen

feat(python-sdk): support bundled preset runtime dependencies

fz vor 1 Monat
Ursprung
Commit
e20f560992

+ 11 - 7
packages/fs/tool-fs-search/src/search-core.ts

@@ -19,6 +19,7 @@
  * @module @deepseek-ai/dsh-tool-fs-search/search-core
  */
 
+import { existsSync } from 'node:fs'
 import { isAbsolute, relative, sep } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
@@ -158,18 +159,21 @@ let rgPathPromise: Promise<string> | undefined
 /**
  * The packaged ripgrep binary path, resolved lazily once per process.
  *
- * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep-<platform>
- * -<arch>`) at module evaluation, so a static import would turn a missing or
- * corrupt platform package (`pnpm install --omit=optional`, partial install)
- * into a failure of the whole Loader composition. Resolving at the call
- * boundary keeps that failure at the first search call as `SEARCH_FAILED` —
- * the package's documented no-load-time-probe contract.
+ * A single-file runtime uses the executable's `-rg` sidecar because a native
+ * helper cannot be spawned from pkg's virtual filesystem. Node-mode builds
+ * fall back to the platform package selected by `@vscode/ripgrep`. Resolving
+ * at the call boundary keeps a missing or corrupt binary at the first search
+ * call as `SEARCH_FAILED`, rather than failing the Loader composition.
  *
  * @returns the packaged binary's absolute path; the memoized promise rejects
  *   when the platform package cannot be resolved.
  */
 export function resolveRgPath(): Promise<string> {
-  rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath)
+  rgPathPromise ??= Promise.resolve().then(async () => {
+    const executableSidecar = `${process.execPath}-rg`
+    if (existsSync(executableSidecar)) return executableSidecar
+    return (await import('@vscode/ripgrep')).rgPath
+  })
   return rgPathPromise
 }
 

+ 2 - 1
packages/shell/tool-bash-persistent/src/index.ts

@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
+import { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal'
 import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
 import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -15,7 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
 const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
 const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
 const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
-const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
+const SHELL_PROMPT = CONTROLLED_PROMPT
 const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
 // One page is enough to find a just-emitted completion marker; the full
 // scrollback is assembled only when a command settles or needs partial output.

+ 4 - 2
packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -84,8 +84,8 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
       '  config:',
       '    pollIntervalMs: 10',
       '    exactProbeAfterMs: 20',
-      '    idleSilenceMs: 100',
-      '    handoffGraceMs: 100',
+      '    idleSilenceMs: 3000',
+      '    handoffGraceMs: 500',
       '    scrollbackLines: 20000',
       '    timeoutMs: 2000',
       '    disposeGraceMs: 500',
@@ -131,7 +131,9 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
     })
 
     expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
+    const startedAt = Date.now()
     await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
+    expect(Date.now() - startedAt).toBeLessThan(2_000)
     const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
     expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
     expect(observed).not.toContain('DSH_PERSISTENT_BASH')

+ 2 - 2
packages/shell/tool-bash-persistent/tests/tools.spec.ts

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import TerminalSessionService from '@deepseek-ai/dsh-terminal'
+import TerminalSessionService, { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal'
 import type {
   TerminalBackend,
   TerminalBackendSession,
@@ -100,7 +100,7 @@ type StubMode =
   | 'paged-scrollback'
 
 class StubPtySession implements TerminalBackendSession {
-  readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
+  readonly motd = CONTROLLED_PROMPT
   readonly pid = 123
   statusValue: TerminalSessionStatus = { kind: 'running' }
   scrollback = this.motd

+ 67 - 13
packages/subprocess/subprocess-local/src/process-inspector.ts

@@ -13,9 +13,9 @@ export interface ProcessIdentity {
 /** Injectable OS process operations used by one local PTY session. */
 export interface ProcessInspector {
   foregroundPgid(shellPid: number): number | undefined
-  isStdinWaiting(pgid: number): boolean
+  isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean
   /** Return the root and its current transitive descendants, children first. */
-  processTree(rootPid: number): ProcessIdentity[]
+  processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[]
   /** Return current members of one POSIX process session when the platform exposes them. */
   processSession(sessionId: number): ProcessIdentity[]
   /** Return whether the exact identity remains a non-quiescent process. */
@@ -226,12 +226,26 @@ function syscallWaitsOnStdin(
   return false
 }
 
+function processWaitsOnStdin(
+  internals: ProcessInspectorInternals,
+  pid: number,
+  processGroupId: number,
+  table: SyscallTable,
+): boolean {
+  if (readLinuxStat(internals, pid)?.pgrp !== processGroupId) return false
+  for (const tid of numericEntries(internals, `/proc/${pid}/task`)) {
+    const syscall = readSyscall(internals, pid, tid)
+    if (syscall !== undefined && syscallWaitsOnStdin(internals, pid, syscall, table)) return true
+  }
+  return false
+}
+
 abstract class PosixProcessInspector implements ProcessInspector {
   constructor(protected readonly internals: ProcessInspectorInternals) {}
 
   abstract foregroundPgid(shellPid: number): number | undefined
-  abstract isStdinWaiting(pgid: number): boolean
-  abstract processTree(rootPid: number): ProcessIdentity[]
+  abstract isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean
+  abstract processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[]
   abstract processSession(sessionId: number): ProcessIdentity[]
   abstract isAlive(identity: ProcessIdentity): boolean
 
@@ -270,6 +284,34 @@ function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdent
   return result
 }
 
+function linuxProcessTreeFromChildren(
+  internals: ProcessInspectorInternals,
+  rootPid: number,
+): ProcessIdentity[] | undefined {
+  const root = readLinuxStat(internals, rootPid)
+  if (root === undefined) return []
+  const visited = new Set<number>()
+  const result: ProcessIdentity[] = []
+  const visit = (entry: ProcStat): boolean => {
+    if (visited.has(entry.pid)) return true
+    visited.add(entry.pid)
+    let children: string
+    try {
+      children = internals.readFile(`/proc/${entry.pid}/task/${entry.pid}/children`)
+    } catch (_unreadableChildren) {
+      return false
+    }
+    for (const token of children.trim().split(/\s+/)) {
+      if (token.length === 0 || !/^\d+$/.test(token)) continue
+      const child = readLinuxStat(internals, Number(token))
+      if (child !== undefined && !visit(child)) return false
+    }
+    result.push({ pid: entry.pid, started: entry.started })
+    return true
+  }
+  return visit(root) ? result : undefined
+}
+
 class LinuxProcessInspector extends PosixProcessInspector {
   constructor(
     private readonly arch: NodeJS.Architecture,
@@ -283,20 +325,32 @@ class LinuxProcessInspector extends PosixProcessInspector {
     return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
   }
 
-  isStdinWaiting(pgid: number): boolean {
+  isStdinWaiting(pgid: number, scanNamespace = true): boolean {
     const table = SYSCALLS[this.arch]
     if (table === undefined) return false
+    // A POSIX process group is normally led by PID == PGID. Interactive shells
+    // wait on stdin in that leader, so inspect it before walking the whole PID
+    // namespace. Large container PID namespaces otherwise make every PTY
+    // readiness poll scan thousands of unrelated processes.
+    if (processWaitsOnStdin(this.internals, pgid, pgid, table)) return true
+    if (!scanNamespace) return false
     for (const pid of numericEntries(this.internals, '/proc')) {
-      if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
-      for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
-        const syscall = readSyscall(this.internals, pid, tid)
-        if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
-      }
+      if (pid === pgid) continue
+      if (processWaitsOnStdin(this.internals, pid, pgid, table)) return true
     }
     return false
   }
 
-  processTree(rootPid: number): ProcessIdentity[] {
+  processTree(rootPid: number, scanNamespace = true): ProcessIdentity[] {
+    // Linux exposes each process's direct children without requiring a scan of
+    // the container's whole PID namespace. Fall back for kernels or procfs
+    // mounts that do not provide the children file.
+    const rooted = linuxProcessTreeFromChildren(this.internals, rootPid)
+    if (rooted !== undefined) return rooted
+    if (!scanNamespace) {
+      const root = readLinuxStat(this.internals, rootPid)
+      return root === undefined ? [] : [{ pid: root.pid, started: root.started }]
+    }
     const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
       const stat = readLinuxStat(this.internals, pid)
       return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
@@ -338,11 +392,11 @@ class MacProcessInspector extends PosixProcessInspector {
     }
   }
 
-  isStdinWaiting(_pgid: number): boolean {
+  isStdinWaiting(_pgid: number, _scanNamespace = true): boolean {
     return false
   }
 
-  processTree(rootPid: number): ProcessIdentity[] {
+  processTree(rootPid: number, _scanNamespace = true): ProcessIdentity[] {
     return processTree(macProcessTable(this.internals), rootPid)
   }
 

+ 10 - 6
packages/subprocess/subprocess-local/src/terminal.ts

@@ -57,7 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     private readonly graceMs: number,
   ) {
     this.pid = terminal.pid
-    this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
+    this.rootIdentity = inspector.processTree(this.pid, false).find(member => member.pid === this.pid)
     this.done = this.outcome.promise
     this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) })
     this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
@@ -81,12 +81,14 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   // Local inspection is synchronous; the seam returns a promise for remote transports.
   // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
   async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
-    this.descendants()
+    // Readiness polling may run every few milliseconds. Track the rooted tree
+    // here, but reserve the full process-session sweep for teardown.
+    this.descendants(false, false)
     const processGroupId = this.inspector.foregroundPgid(this.pid)
     if (processGroupId === undefined) return undefined
     return {
       processGroupId,
-      inputWaiting: this.inspector.isStdinWaiting(processGroupId),
+      inputWaiting: this.inspector.isStdinWaiting(processGroupId, false),
     }
   }
 
@@ -141,20 +143,22 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     return members.filter(member => this.inspector.isAlive(member))
   }
 
-  private descendants(): ProcessIdentity[] {
+  private descendants(includeSession = true, scanNamespace = true): ProcessIdentity[] {
     // Adopt newly scanned members only while the numeric root pid provably
     // still carries the spawned shell's start identity: after the shell dies,
     // a recycled pid's tree and session must not donate an unrelated
     // process's children to this session's signalling. Already-adopted
     // members keep their own start identities, which every signal rechecks.
-    const tree = this.inspector.processTree(this.pid)
+    const tree = this.inspector.processTree(this.pid, scanNamespace)
     const root = tree.find(member => member.pid === this.pid)
     const rootVerified = this.rootIdentity !== undefined
       && root !== undefined
       && root.started === this.rootIdentity.started
     this.trackedDescendants = this.survivors(this.unionMembers(
       this.trackedDescendants,
-      ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [],
+      ...rootVerified
+        ? [tree, ...includeSession ? [this.inspector.processSession(this.pid)] : []]
+        : [],
     ).filter(member => member.pid !== this.pid))
     return this.trackedDescendants
   }

+ 35 - 0
packages/subprocess/subprocess-local/tests/process-inspector.spec.ts

@@ -95,6 +95,9 @@ describe('Linux process inspector', () => {
     fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
     fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
     fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
+    fake.files.set('/proc/10/task/10/children', '12')
+    fake.files.set('/proc/12/task/12/children', '13')
+    fake.files.set('/proc/13/task/13/children', '')
     const inspector = createProcessInspector('linux', 'x64', fake.internals)
     expect(inspector.foregroundPgid(10)).toBe(40)
     expect(inspector.foregroundPgid(11)).toBeUndefined()
@@ -124,6 +127,28 @@ describe('Linux process inspector', () => {
     expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
   })
 
+  it('walks a rooted process tree without enumerating the PID namespace', () => {
+    const fake = fakeInternals()
+    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
+    fake.files.set('/proc/10/task/10/children', '11')
+    fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10))
+    fake.files.set('/proc/11/task/11/children', '')
+
+    expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([
+      { pid: 11, started: '501' },
+      { pid: 10, started: '500' },
+    ])
+  })
+
+  it('keeps readiness inspection local when procfs has no children index', () => {
+    const fake = fakeInternals()
+    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
+    const inspector = createProcessInspector('linux', 'x64', fake.internals)
+
+    expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }])
+    expect(inspector.isStdinWaiting(10, false)).toBe(false)
+  })
+
   it('detects read, select, poll, and epoll waits across non-leader threads', () => {
     const fake = fakeInternals()
     fake.dirs.set('/proc', ['100', '101'])
@@ -156,6 +181,16 @@ describe('Linux process inspector', () => {
     expect(inspector.isStdinWaiting(77)).toBe(true)
   })
 
+  it('checks a waiting process-group leader without scanning the PID namespace', () => {
+    const fake = fakeInternals()
+    fake.files.set('/proc/77/stat', stat(77, 77, 77, 77, '1'))
+    fake.dirs.set('/proc/77/task', ['77'])
+    fake.files.set('/proc/77/task/77/syscall', syscall(0, 0))
+
+    const inspector = createProcessInspector('linux', 'x64', fake.internals)
+    expect(inspector.isStdinWaiting(77)).toBe(true)
+  })
+
   it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
     const fake = fakeInternals()
     fake.dirs.set('/proc', ['100'])

+ 2 - 3
packages/terminal/terminal-bash/src/sanitize.ts

@@ -2,12 +2,11 @@
 
 import { Buffer } from 'node:buffer'
 
+export { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal'
+
 /** OSC marker emitted by the controlled bash before each prompt. */
 export const PROMPT_MARKER_PREFIX = '133;D;'
 
-/** Exact printable prompt emitted after the private marker. */
-export const CONTROLLED_PROMPT = 'dsh> '
-
 /** One sanitized chunk plus whether it contained the owned prompt marker. */
 export interface SanitizedChunk {
   text: string

+ 3 - 0
packages/terminal/terminal/src/index.ts

@@ -42,6 +42,9 @@ export type {
 } from './types.ts'
 export { TerminalBackendCleanupError } from './types.ts'
 
+/** Printable prompt shared by terminal backends and persistent shell consumers. */
+export const CONTROLLED_PROMPT = 'dsh> '
+
 /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */
 export type TerminalSessionId = TerminalSessionIdValue
 

+ 18 - 0
pnpm-lock.yaml

@@ -8340,6 +8340,9 @@ importers:
       '@deepseek-ai/dsh-agent-spine-demo':
         specifier: workspace:^
         version: link:../../packages/examples/agent-spine-demo
+      '@deepseek-ai/dsh-agent-tool-presentation':
+        specifier: workspace:^
+        version: link:../../packages/core/agent-tool-presentation
       '@deepseek-ai/dsh-anonymous-user-id':
         specifier: workspace:^
         version: link:../../packages/identity/anonymous-user-id
@@ -8361,6 +8364,9 @@ importers:
       '@deepseek-ai/dsh-code-runtime-worker-thread':
         specifier: workspace:^
         version: link:../../packages/code-runtime/code-runtime-worker-thread
+      '@deepseek-ai/dsh-command-compact':
+        specifier: workspace:^
+        version: link:../../packages/compaction/command-compact
       '@deepseek-ai/dsh-command-goal':
         specifier: workspace:^
         version: link:../../packages/goal/command-goal
@@ -8442,6 +8448,9 @@ importers:
       '@deepseek-ai/dsh-permission-presets':
         specifier: workspace:^
         version: link:../../packages/interaction/permission-presets
+      '@deepseek-ai/dsh-persona':
+        specifier: workspace:^
+        version: link:../../packages/preset/persona
       '@deepseek-ai/dsh-plan-mode':
         specifier: workspace:^
         version: link:../../packages/plan/plan-mode
@@ -8514,6 +8523,9 @@ importers:
       '@deepseek-ai/dsh-skill-filesystem':
         specifier: workspace:^
         version: link:../../packages/skill/skill-filesystem
+      '@deepseek-ai/dsh-spill':
+        specifier: workspace:^
+        version: link:../../packages/spill/spill
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../../packages/subagent/subagent
@@ -8568,12 +8580,18 @@ importers:
       '@deepseek-ai/dsh-tool-fs':
         specifier: workspace:^
         version: link:../../packages/fs/tool-fs
+      '@deepseek-ai/dsh-tool-fs-search':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-fs-search
       '@deepseek-ai/dsh-tool-goal':
         specifier: workspace:^
         version: link:../../packages/goal/tool-goal
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../packages/jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-ralph':
+        specifier: workspace:^
+        version: link:../../packages/workflow/tool-ralph
       '@deepseek-ai/dsh-tool-skill':
         specifier: workspace:^
         version: link:../../packages/skill/tool-skill

+ 1 - 1
python/sdk-runtime/hatch_build.py

@@ -67,7 +67,7 @@ class RuntimeBuildHook(BuildHookInterface):
         expected_executable = matches[0][1]
         runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime"
         runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else [])
-        expected_files = [expected_executable]
+        expected_files = [expected_executable, f"{expected_executable}-rg"]
         if "-macos-" in expected_executable:
             expected_files.append(f"{expected_executable}-spawn-helper")
         found_files = [path.name for path in runtime_files]

+ 6 - 0
python/sdk-runtime/package.json

@@ -14,6 +14,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
+    "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-shell": "workspace:^",
@@ -22,6 +23,7 @@
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-code-runtime": "workspace:^",
     "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^",
+    "@deepseek-ai/dsh-command-compact": "workspace:^",
     "@deepseek-ai/dsh-command-goal": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
@@ -49,6 +51,7 @@
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-permission-presets": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
+    "@deepseek-ai/dsh-persona": "workspace:^",
     "@deepseek-ai/dsh-terminal": "workspace:^",
     "@deepseek-ai/dsh-terminal-bash": "workspace:^",
     "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^",
@@ -71,6 +74,7 @@
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-skill-filesystem": "workspace:^",
+    "@deepseek-ai/dsh-spill": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-acp": "workspace:^",
     "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
@@ -89,7 +93,9 @@
     "@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-ralph": "workspace:^",
     "@deepseek-ai/dsh-tool-skill": "workspace:^",
     "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
     "@deepseek-ai/dsh-tool-subagent": "workspace:^",

+ 8 - 2
python/sdk-runtime/src/deepseek_harness_runtime/__init__.py

@@ -5,8 +5,8 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's
 
 - **exe (production)**: single-file Node executables named
   ``dsh-jsonrpc-agent-pkg-<platform>-<arch>`` (platform in {linux, macos}, arch in
-  {x64, arm64}); macOS also uses a sibling ``-spawn-helper``. The target machine
-  needs no Node installation.
+  {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling
+  ``-spawn-helper``. The target machine needs no Node installation.
 - **node (dev-only)**: the full deploy closure under ``runtime/node/``
   (``package.json`` + ``node_modules/``), executed as ``node
   runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`` on a
@@ -83,6 +83,12 @@ def bundled_runtime_path() -> Path:
             f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. "
             + _EXE_ACQUISITION_HINT
         )
+    ripgrep = Path(f"{path}-rg")
+    if not ripgrep.is_file():
+        raise FileNotFoundError(
+            f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. "
+            + _EXE_ACQUISITION_HINT
+        )
     if tag.startswith("macos-"):
         helper = Path(f"{path}-spawn-helper")
         if not helper.is_file():

+ 4 - 0
python/sdk/tests/test_release_version.py

@@ -92,6 +92,10 @@ def test_stage_runtime_copies_platform_payload(
     executable.write_bytes(b"runtime")
     executable.chmod(0o755)
     expected = {executable.name: b"runtime"}
+    ripgrep = Path(f"{executable}-rg")
+    ripgrep.write_bytes(b"ripgrep")
+    ripgrep.chmod(0o755)
+    expected[ripgrep.name] = b"ripgrep"
     if with_helper:
         spawn_helper = Path(f"{executable}-spawn-helper")
         spawn_helper.write_bytes(b"helper")

+ 27 - 2
scripts/build-exe-for-python-sdk.ts

@@ -396,7 +396,8 @@ class SingleExeBuild {
     if (!this.cli.dryRun && !existsSync(product)) {
       throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
     }
-    if (target.platform !== 'macos') return [product]
+    const ripgrep = await this.copyRipgrepSidecar(target, product)
+    if (target.platform !== 'macos') return [product, ripgrep]
     const spawnHelper = `${product}-spawn-helper`
     const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
     if (this.cli.dryRun) {
@@ -405,7 +406,31 @@ class SingleExeBuild {
       await copyFile(source, spawnHelper)
       await chmod(spawnHelper, 0o755)
     }
-    return [product, spawnHelper]
+    return [product, ripgrep, spawnHelper]
+  }
+
+  /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
+  private async copyRipgrepSidecar(target: Target, product: string): Promise<string> {
+    const platform = target.platform === 'macos' ? 'darwin' : target.platform
+    const source = join(
+      this.staging,
+      'node_modules',
+      '@vscode',
+      `ripgrep-${platform}-${target.arch}`,
+      'bin',
+      'rg',
+    )
+    const destination = `${product}-rg`
+    if (this.cli.dryRun) {
+      console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
+      return destination
+    }
+    if (!existsSync(source)) {
+      throw new Error(`build-exe-for-python-sdk: target ripgrep binary is missing at ${source}.`)
+    }
+    await copyFile(source, destination)
+    await chmod(destination, 0o755)
+    return destination
   }
 
   /**

+ 2 - 1
scripts/build-python-release.py

@@ -48,7 +48,8 @@ PLATFORMS = load_platforms()
 
 
 def runtime_suffixes(executable_name: str) -> tuple[str, ...]:
-    return ("", "-spawn-helper") if "-macos-" in executable_name else ("",)
+    suffixes = ("", "-rg")
+    return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes
 
 
 def main() -> None:

+ 93 - 2
scripts/smoke-python-runtime.py

@@ -29,6 +29,9 @@ MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and stri
 MINIMAL_TEXT = "minimal agent smoke ok"
 MINIMAL_EDITOR_PATH_PREFIX = "Editor path: "
 MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant."
+FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools."
+FS_SEARCH_TEXT = "filesystem search smoke ok"
+FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK"
 MINIMAL_CORDIS = (
     Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml"
 )
@@ -109,6 +112,29 @@ CUSTOM_CORDIS = """\
 - id: cordis-tool
   name: '@deepseek-ai/dsh-tool-cordis'
 """
+FS_SEARCH_CORDIS = """\
+- id: sdk-jsonrpc-server
+  name: '@deepseek-ai/dsh-sdk-jsonrpc-server'
+- id: agent-core
+  name: '@deepseek-ai/dsh-agent-spine-demo'
+  config:
+    workspaceContext: false
+    skills:
+      enabled: false
+    toolBash: false
+    toolJobs: false
+- id: sessions
+  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+  config:
+    root: !!js process.env.DSH_SESSION_ROOT
+    compression: 'none'
+- id: subprocess
+  name: '@deepseek-ai/dsh-subprocess-local'
+- id: fs-search
+  name: '@deepseek-ai/dsh-tool-fs-search'
+  config:
+    sampleOverCapGlobResults: false
+"""
 class MockModelHandler(BaseHTTPRequestHandler):
     """Return deterministic text, worker, and orchestration completions."""
 
@@ -143,6 +169,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
     if latest.get("role") == "tool":
         call_id, tool_name = latest_tool_call(messages)
         tool_text = message_text(latest.get("content"))
+        fs_search = fs_search_tool_followup(call_id, tool_name, tool_text)
+        if fs_search is not None:
+            return fs_search
         minimal = minimal_tool_followup(body, call_id, tool_name, tool_text)
         if minimal is not None:
             return minimal
@@ -192,6 +221,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
         SNAPSHOT_PROMPT,
         CODE_PROMPT,
         WORKFLOW_PROMPT,
+        FS_SEARCH_PROMPT,
     }
     prompt = next(
         (candidate for candidate in user_prompts if candidate in scenario_prompts),
@@ -233,9 +263,40 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
                 },
             },
         )
+    if prompt == FS_SEARCH_PROMPT:
+        assert_advertised_tool(body, "grep")
+        assert_advertised_tool(body, "glob")
+        return tool_call_chunks(
+            "fs-search-grep",
+            "grep",
+            {"pattern": FS_SEARCH_MARKER, "path": "."},
+        )
     return text_chunks(EXPECTED_TEXT)
 
 
+def fs_search_tool_followup(
+    call_id: str,
+    tool_name: str,
+    tool_text: str,
+) -> list[dict[str, object]] | None:
+    """Exercise both ripgrep-backed tools through the packaged executable."""
+    if not call_id.startswith("fs-search-"):
+        return None
+    if call_id == "fs-search-grep" and tool_name == "grep":
+        if "needle.txt" not in tool_text or FS_SEARCH_MARKER not in tool_text:
+            raise AssertionError(f"packaged grep returned no marker: {tool_text}")
+        return tool_call_chunks(
+            "fs-search-glob",
+            "glob",
+            {"pattern": "**/*.txt"},
+        )
+    if call_id == "fs-search-glob" and tool_name == "glob":
+        if "needle.txt" not in tool_text:
+            raise AssertionError(f"packaged glob returned no fixture path: {tool_text}")
+        return text_chunks(FS_SEARCH_TEXT)
+    raise AssertionError(f"unexpected filesystem-search follow-up: {call_id} {tool_name}: {tool_text}")
+
+
 def minimal_tool_followup(
     body: dict[str, object],
     call_id: str,
@@ -477,13 +538,13 @@ def main() -> None:
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument(
         "--scenario",
-        choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"),
+        choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"),
         default="all",
     )
     parser.add_argument("--exe", type=Path)
     parser.add_argument("--update-snapshots", action="store_true")
     args = parser.parse_args()
-    if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None:
+    if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None:
         parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
     if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
         parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
@@ -499,6 +560,9 @@ def main() -> None:
         if args.scenario in {"all", "sdk-minimal"}:
             assert args.exe is not None
             smoke_sdk_minimal(model.url, args.exe.resolve())
+        if args.scenario in {"all", "sdk-fs-search"}:
+            assert args.exe is not None
+            smoke_sdk_fs_search(model.url, args.exe.resolve())
         if args.scenario in {"all", "sdk-snapshot"}:
             assert args.exe is not None
             smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
@@ -588,6 +652,33 @@ def smoke_sdk_minimal(base_url: str, executable: Path) -> None:
         assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
 
 
+def smoke_sdk_fs_search(base_url: str, executable: Path) -> None:
+    """Exercise real grep and glob spawns through the packaged executable."""
+    from deepseek_harness import DeepSeekHarness
+
+    with tempfile.TemporaryDirectory(prefix="dsh-sdk-fs-search-") as temporary:
+        root = Path(temporary).resolve()
+        (root / "needle.txt").write_text(f"{FS_SEARCH_MARKER}\n")
+        sessions = root / "sessions"
+        cordis = root / "cordis.yml"
+        cordis.write_text(FS_SEARCH_CORDIS)
+        with DeepSeekHarness(
+            provider="deepseek-official",
+            model="smoke-model",
+            cwd=str(root),
+            session_root=str(sessions),
+            cordis=str(cordis),
+            runtime_bin=str(executable),
+            api_key="sk-keyless-smoke",
+            base_url=base_url,
+            request_timeout_seconds=60,
+        ) as harness:
+            result = harness.run(FS_SEARCH_PROMPT, session_id="fs-search-smoke")
+
+        assert result.final_response == FS_SEARCH_TEXT, result.final_response
+        assert_session_log(sessions, root, FS_SEARCH_TEXT, FS_SEARCH_MARKER, "needle.txt")
+
+
 def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
     """Drive and compare the advanced SDK/executable behavioral snapshot."""
     from deepseek_harness import DeepSeekHarness

+ 115 - 8
scripts/verify-runtime-closure.ts

@@ -1,12 +1,18 @@
 /**
- * Verify that the executable deploy manifest supplies every required workspace
- * peer in its dependency graph. With auto peer installation disabled, a missing
- * root peer can otherwise fail only when Cordis loads the packaged plugin.
+ * Verify that the executable deploy manifest supplies every plugin referenced
+ * by a shipped agent preset and every required workspace peer in its dependency
+ * graph. With auto peer installation disabled, either omission can otherwise
+ * fail only when Cordis loads the packaged plugin.
  */
 import { globSync } from 'node:fs'
 import { readFile } from 'node:fs/promises'
-import { resolve } from 'node:path'
+import { basename, dirname, resolve } from 'node:path'
 import { parseArgs } from 'node:util'
+import * as yaml from 'js-yaml'
+
+interface JsExpr {
+  __jsExpr: string
+}
 
 interface PackageManifest {
   name?: string
@@ -21,6 +27,23 @@ interface WorkspacePackage {
   manifest: PackageManifest
 }
 
+interface RuntimePlatform {
+  tag: string
+  executable: string
+}
+
+type RuntimePlatformManifest = Record<string, RuntimePlatform>
+
+const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
+  kind: 'scalar',
+  resolve: data => typeof data === 'string',
+  construct: (data: unknown): JsExpr => {
+    if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
+    return { __jsExpr: data }
+  },
+})
+const schema = yaml.JSON_SCHEMA.extend(jsExprType)
+
 const root = resolve(import.meta.dirname, '..')
 const { values } = parseArgs({
   args: process.argv.slice(2),
@@ -31,6 +54,7 @@ const runtimeManifest = await loadManifest(runtimeManifestPath)
 const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime'
 const workspace = await loadWorkspacePackages()
 const runtimeDependencies = runtimeManifest.dependencies ?? {}
+const platforms = await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json'))
 const parents = new Map<string, string | undefined>()
 const queue: string[] = []
 
@@ -40,7 +64,7 @@ for (const dependency of Object.keys(runtimeDependencies).sort()) {
   queue.push(dependency)
 }
 
-const failures: string[] = []
+const failures = await missingPresetPlugins(runtimeDependencies, platforms)
 for (let index = 0; index < queue.length; index += 1) {
   const packageName = queue[index]
   if (packageName === undefined) continue
@@ -65,12 +89,91 @@ for (let index = 0; index < queue.length; index += 1) {
 }
 
 if (failures.length > 0) {
-  console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:')
+  console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:')
   for (const failure of failures) console.error(`  ${failure}`)
   process.exit(1)
 }
 
-console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`)
+const presetCount = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length
+console.log(
+  `verify-runtime-closure: ${presetCount} agent presets and ${queue.length} workspace packages form a closed runtime dependency graph.`,
+)
+
+async function missingPresetPlugins(
+  runtimeDependencies: Readonly<Record<string, string>>,
+  platforms: RuntimePlatformManifest,
+): Promise<string[]> {
+  const missing = new Map<string, Set<string>>()
+  const failures: string[] = []
+  const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort()
+  for (const presetPath of presetPaths) {
+    const document: unknown = yaml.load(await readFile(resolve(root, presetPath), 'utf8'), { schema })
+    if (!Array.isArray(document)) {
+      failures.push(`${presetPath}: preset root must be a Loader entry array`)
+      continue
+    }
+    for (const target of Object.keys(platforms).sort()) {
+      const processPlatform = processPlatformForTarget(target)
+      for (const plugin of activeBarePluginPackages(document, processPlatform)) {
+        if (runtimeDependencies[plugin] !== undefined) continue
+        const preset = basename(dirname(presetPath))
+        const key = `${preset} preset -> ${plugin}`
+        const targets = missing.get(key) ?? new Set<string>()
+        targets.add(target)
+        missing.set(key, targets)
+      }
+    }
+  }
+  failures.push(...[...missing.entries()].map(([chain, targets]) =>
+    `${chain} (${[...targets].sort().join(', ')})`))
+  return failures
+}
+
+function activeBarePluginPackages(entries: unknown[], processPlatform: string): Set<string> {
+  const packages = new Set<string>()
+  const visit = (value: unknown, parentDisabled: boolean): void => {
+    if (!isRecord(value)) return
+    const disabled = parentDisabled || disabledOnPlatform(value.disabled, processPlatform)
+    if (disabled) return
+    if (typeof value.name === 'string') {
+      const packageName = barePackageName(value.name)
+      if (packageName !== undefined) packages.add(packageName)
+    }
+    if (Array.isArray(value.config)) {
+      for (const child of value.config) visit(child, disabled)
+    }
+  }
+  for (const entry of entries) visit(entry, false)
+  return packages
+}
+
+function disabledOnPlatform(value: unknown, processPlatform: string): boolean {
+  if (typeof value === 'boolean') return value
+  if (!isRecord(value) || typeof value.__jsExpr !== 'string') return false
+  const match = /^process\.platform\s*(===|!==)\s*(['"])(win32|linux|darwin)\2$/.exec(value.__jsExpr.trim())
+  if (match === null) return false
+  const [, operator, , expected] = match
+  return operator === '===' ? processPlatform === expected : processPlatform !== expected
+}
+
+function processPlatformForTarget(target: string): string {
+  if (target.startsWith('linux-')) return 'linux'
+  if (target.startsWith('macos-')) return 'darwin'
+  throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`)
+}
+
+function barePackageName(specifier: string): string | undefined {
+  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) return undefined
+  const parts = specifier.split('/')
+  if (specifier.startsWith('@')) {
+    return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined
+  }
+  return parts[0] || undefined
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
 
 async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
   const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
@@ -85,7 +188,11 @@ async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
 }
 
 async function loadManifest(path: string): Promise<PackageManifest> {
-  return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
+  return loadJson<PackageManifest>(path)
+}
+
+async function loadJson<T>(path: string): Promise<T> {
+  return JSON.parse(await readFile(path, 'utf8')) as T
 }
 
 function formatChain(