diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 1dddb99373..e4580f7301 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/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 | undefined /** * The packaged ripgrep binary path, resolved lazily once per process. * - * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep- - * -`) 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 { - 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 } diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 16d127bbe2..b13911cb25 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/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 = '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.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\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. diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 55e89bc9f4..f04981f2cd 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/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') diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index b3de46643c..88a9ffa3f4 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/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 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..06a44665cf 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/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() + 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) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 6d818c8a7f..bfba98dfb9 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/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 { - 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 } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..84cae7564a 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/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']) diff --git a/packages/terminal/terminal-bash/src/sanitize.ts b/packages/terminal/terminal-bash/src/sanitize.ts index 1f28315fb1..73f9d74e7b 100644 --- a/packages/terminal/terminal-bash/src/sanitize.ts +++ b/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 diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index d9e5444465..6f782e3bab 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1490a0f9f7..2962e3c89d 100644 --- a/pnpm-lock.yaml +++ b/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 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 400d9d585b..ef5a621f9d 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/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] diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 89d4b81b22..083a8a87d7 100644 --- a/python/sdk-runtime/package.json +++ b/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:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 16af912def..727ee29586 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/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 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(): diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7ff9b15d59..b21a485949 100644 --- a/python/sdk/tests/test_release_version.py +++ b/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") diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index da1cea67c4..c104d47ef5 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/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 { + 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 } /** diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 18660b3411..0b4157f40d 100644 --- a/scripts/build-python-release.py +++ b/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: diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 700cfb7c79..8eb079fd78 100644 --- a/scripts/smoke-python-runtime.py +++ b/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 diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c87d562f59..c0015fe11a 100644 --- a/scripts/verify-runtime-closure.ts +++ b/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 + +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(resolve(root, 'python/sdk-runtime/platforms.json')) const parents = new Map() 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>, + platforms: RuntimePlatformManifest, +): Promise { + const missing = new Map>() + 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() + 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 { + const packages = new Set() + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} async function loadWorkspacePackages(): Promise> { const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) @@ -85,7 +188,11 @@ async function loadWorkspacePackages(): Promise> { } async function loadManifest(path: string): Promise { - return JSON.parse(await readFile(path, 'utf8')) as PackageManifest + return loadJson(path) +} + +async function loadJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as T } function formatChain(