diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 2259a1b..1c29355 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -599,7 +599,7 @@ export function resolveRequiredCodeExecutor(userMessage) { const executor = String( runMetadata?.executor ?? runMetadata?.requiredExecutor ?? '', ).trim().toLowerCase(); - return ['aider', 'openhands', 'cursor'].includes(executor) ? executor : null; + return ['aider', 'openhands', 'cursor', 'dsh'].includes(executor) ? executor : null; } export function assertRequiredCodeExecutorAvailable(requiredExecutor, toolGatewayStatus) { diff --git a/docs/goose-v149-canary.env.example b/docs/goose-v149-canary.env.example index abbe96b..65a0727 100644 --- a/docs/goose-v149-canary.env.example +++ b/docs/goose-v149-canary.env.example @@ -28,7 +28,14 @@ MEMORY_CANDIDATE_ENABLED=0 # MEMIND_HEADROOM_MODE=off # MEMIND_HEADROOM_PROXY_BASE_URL=http://127.0.0.1:8787/v1 # MEMIND_HEADROOM_UPSTREAM_BASE_URL=http://127.0.0.1:18036/v1 +# OPENAI_TARGET_API_URL=http://127.0.0.1:18036/v1 # HEADROOM_OUTPUT_SHAPER=0 +# MEMIND_HEADROOM_OBS_MODEL=deepseek-chat + +# DeepSeek Harness code executor (developer preview, default off) +# MEMIND_TOOL_GATEWAY_DSH_ENABLED=0 +# MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES=repo_refactor,multi_file +# MEMIND_DSH_BIN=dsh # Context injection budget (Phase 2, default off — does not open memory injection) # MEMIND_CONTEXT_BUDGET_MODE=off diff --git a/dsh-agent-launch.mjs b/dsh-agent-launch.mjs new file mode 100644 index 0000000..d821a6b --- /dev/null +++ b/dsh-agent-launch.mjs @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function dshExecutorEnabled(env = process.env) { + return envFlag(env.MEMIND_TOOL_GATEWAY_DSH_ENABLED, false); +} + +export function expandHome(value) { + const text = String(value ?? '').trim(); + if (!text) return text; + if (text.startsWith('~/')) return path.join(os.homedir(), text.slice(2)); + if (text === '~') return os.homedir(); + return text; +} + +export function resolveDshCommand(env = process.env) { + if (envFlag(env.MEMIND_DSH_FORCE_NPX, false)) { + return { + command: String(env.MEMIND_DSH_NPX_BIN ?? 'npx').trim() || 'npx', + viaNpx: true, + }; + } + + const fileCandidates = [ + env.MEMIND_DSH_BIN, + env.DSH_BIN, + '~/.local/bin/dsh', + ].map((item) => expandHome(String(item ?? '').trim())).filter(Boolean); + + for (const candidate of fileCandidates) { + if (fs.existsSync(candidate)) { + return { command: candidate, viaNpx: false }; + } + } + + const bare = String(env.DSH_BIN ?? 'dsh').trim(); + if (bare && !bare.includes('/')) { + return { command: bare, viaNpx: false }; + } + + return { + command: String(env.MEMIND_DSH_NPX_BIN ?? 'npx').trim() || 'npx', + viaNpx: true, + }; +} + +export function buildDshExecutorInstruction(instruction) { + const base = String(instruction ?? '').trim(); + if (!base) return base; + return [ + base, + '', + 'Execution constraints:', + '- Work only inside the provided workspace cwd', + '- Do not run git commit/push or production deploy commands', + '- Prefer minimal, reviewable diffs', + '- Summarize changed files in the final answer', + ].join('\n'); +} + +export function buildDshExecutorLaunchPlan({ + cwd, + instruction, + env = process.env, + runtimeEnv = {}, +} = {}) { + if (!dshExecutorEnabled(env)) { + return { + ok: false, + executor: 'dsh', + message: 'DeepSeek Harness 执行器未启用(MEMIND_TOOL_GATEWAY_DSH_ENABLED)', + }; + } + + const workspace = path.resolve(String(cwd ?? env.MEMIND_DSH_EXECUTOR_WORKSPACE ?? process.cwd())); + const prompt = buildDshExecutorInstruction(instruction); + if (!prompt) { + return { + ok: false, + executor: 'dsh', + message: 'DeepSeek Harness 执行器需要 instruction', + }; + } + + const resolved = resolveDshCommand(env); + const args = resolved.viaNpx + ? ['-y', '@deepseek-ai/dsh', '--profile', 'headless', prompt] + : ['--profile', 'headless', prompt]; + + const launchEnv = { + ...runtimeEnv, + }; + if (!launchEnv.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY) { + launchEnv.DEEPSEEK_API_KEY = env.DEEPSEEK_API_KEY; + } + + return { + ok: true, + executor: 'dsh', + executorLabel: 'DeepSeek Harness', + cwd: workspace, + command: resolved.command, + args, + env: launchEnv, + notes: [ + 'DeepSeek Harness headless one-shot task via dsh --profile headless.', + 'Developer preview: keep disabled by default and route only whitelisted task types.', + ], + }; +} diff --git a/dsh-agent-launch.test.mjs b/dsh-agent-launch.test.mjs new file mode 100644 index 0000000..324ab15 --- /dev/null +++ b/dsh-agent-launch.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildDshExecutorLaunchPlan, + dshExecutorEnabled, + resolveDshCommand, +} from './dsh-agent-launch.mjs'; + +test('dshExecutorEnabled defaults to false', () => { + assert.equal(dshExecutorEnabled({}), false); + assert.equal(dshExecutorEnabled({ MEMIND_TOOL_GATEWAY_DSH_ENABLED: '1' }), true); +}); + +test('resolveDshCommand falls back to npx package when forced', () => { + const resolved = resolveDshCommand({ + MEMIND_DSH_FORCE_NPX: '1', + MEMIND_DSH_NPX_BIN: 'npx', + }); + assert.equal(resolved.viaNpx, true); + assert.equal(resolved.command, 'npx'); +}); + +test('buildDshExecutorLaunchPlan requires enable flag', () => { + const plan = buildDshExecutorLaunchPlan({ + cwd: '/tmp/work', + instruction: 'fix tests', + env: {}, + }); + assert.equal(plan.ok, false); +}); + +test('buildDshExecutorLaunchPlan builds headless args when enabled', () => { + const plan = buildDshExecutorLaunchPlan({ + cwd: '/tmp/work', + instruction: 'fix tests', + env: { MEMIND_TOOL_GATEWAY_DSH_ENABLED: '1', MEMIND_DSH_NPX_BIN: 'npx' }, + runtimeEnv: { DEEPSEEK_API_KEY: 'test-key' }, + }); + assert.equal(plan.ok, true); + assert.equal(plan.executor, 'dsh'); + assert.ok(plan.args.includes('headless')); + assert.ok(plan.args.at(-1).includes('fix tests')); + assert.equal(plan.env.DEEPSEEK_API_KEY, 'test-key'); +}); diff --git a/executor-display-label.mjs b/executor-display-label.mjs index 720f867..f6be001 100644 --- a/executor-display-label.mjs +++ b/executor-display-label.mjs @@ -2,6 +2,7 @@ const EXECUTOR_DISPLAY_LABELS = Object.freeze({ cursor: 'TKMind 智趣', aider: 'Aider', openhands: 'OpenHands', + dsh: 'DeepSeek Harness', goose: 'TKMind', }); diff --git a/llm-providers.mjs b/llm-providers.mjs index b3b4d57..9185385 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -20,6 +20,7 @@ import { buildCursorExecutorLaunchPlan, cursorExecutorEnabled, } from './cursor-agent-launch.mjs'; +import { buildDshExecutorLaunchPlan } from './dsh-agent-launch.mjs'; import { probeHeadroomProxyReachable, resolveGoosedApiUrlWithHeadroom, @@ -116,6 +117,12 @@ export const LLM_EXECUTOR_CATALOG = [ description: 'MindSpace 页面与代码任务,通过 TKMind 智趣执行器落盘', purposes: ['default'], }, + { + id: 'dsh', + label: 'DeepSeek Harness', + description: 'Cordis 插件化 code harness(developer preview,headless one-shot)', + purposes: ['default'], + }, ]; const catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item])); @@ -498,6 +505,25 @@ function executorEnvForProfile(executor, profile, model, { includeSecret = false }; } + if (executor === 'dsh') { + const env = { + ...common, + LLM_MODEL: model, + }; + if (includeSecret) { + env.DEEPSEEK_API_KEY = profile.apiKey; + env.OPENAI_API_KEY = profile.apiKey; + if (providerId === 'openrouter') env.OPENROUTER_API_KEY = profile.apiKey; + } else { + env.DEEPSEEK_API_KEY = '[hidden]'; + } + if (baseUrl) { + env.OPENAI_BASE_URL = baseUrl; + env.DEEPSEEK_API_BASE = baseUrl; + } + return env; + } + return { ...common, GOOSE_PROVIDER: profile.providerKind === 'custom' @@ -545,7 +571,14 @@ function resolveExecutorCommand(executor) { '~/.local/bin/agent', 'agent', ] - : [executor]; + : executor === 'dsh' + ? [ + process.env.MEMIND_DSH_BIN, + process.env.DSH_BIN, + '~/.local/bin/dsh', + 'dsh', + ] + : [executor]; const resolved = candidates.map((item) => String(item ?? '').trim()).find(Boolean) ?? executor; if (resolved.startsWith('~/')) { return path.join(os.homedir(), resolved.slice(2)); @@ -732,6 +765,15 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { }); } + if (runtime.executor === 'dsh') { + return buildDshExecutorLaunchPlan({ + cwd, + instruction, + env: process.env, + runtimeEnv: env, + }); + } + return { ok: false, executor: runtime.executor, diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index 5f72953..f2129fa 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -320,7 +320,7 @@ test('listExecutorBindings returns all executor placeholders', async () => { const bindings = await service.listExecutorBindings(); assert.deepEqual( bindings.map((binding) => binding.executor), - ['goose', 'aider', 'openhands', 'cursor'], + ['goose', 'aider', 'openhands', 'cursor', 'dsh'], ); assert.equal(bindings[0].enabled, false); }); @@ -649,6 +649,34 @@ test('buildExecutorLaunchPlan rejects openhands headless without instruction', ( assert.match(plan.message ?? '', /instruction/); }); +test('buildExecutorLaunchPlan creates dsh headless command when enabled', () => { + const previous = process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED; + process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED = '1'; + process.env.MEMIND_DSH_FORCE_NPX = '1'; + try { + const plan = buildExecutorLaunchPlan({ + ok: true, + executor: 'dsh', + executorLabel: 'DeepSeek Harness', + purpose: 'default', + env: { DEEPSEEK_API_KEY: 'test-key' }, + }, { + cwd: '/tmp/mindspace/user-1', + instruction: 'run unit tests', + }); + assert.equal(plan.ok, true); + assert.equal(plan.executor, 'dsh'); + assert.equal(plan.command, 'npx'); + assert.ok(plan.args.includes('@deepseek-ai/dsh')); + assert.ok(plan.args.includes('headless')); + assert.match(plan.args.at(-1) ?? '', /unit tests/); + } finally { + if (previous === undefined) delete process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED; + else process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED = previous; + delete process.env.MEMIND_DSH_FORCE_NPX; + } +}); + test('buildExecutorLaunchPlan creates cursor headless command when enabled', () => { const previous = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = '1'; diff --git a/scripts/check-dsh-executor-local.mjs b/scripts/check-dsh-executor-local.mjs new file mode 100644 index 0000000..d27f3bc --- /dev/null +++ b/scripts/check-dsh-executor-local.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Local DeepSeek Harness executor dry-run probe. + */ +import { buildDshExecutorLaunchPlan, dshExecutorEnabled } from '../dsh-agent-launch.mjs'; + +if (!dshExecutorEnabled(process.env)) { + console.log('DSH_EXECUTOR_SKIP: MEMIND_TOOL_GATEWAY_DSH_ENABLED is off'); + process.exit(0); +} + +const plan = buildDshExecutorLaunchPlan({ + cwd: process.cwd(), + instruction: process.env.MEMIND_DSH_SMOKE_TASK ?? 'List three files in the workspace root and stop.', + env: process.env, + runtimeEnv: { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY ?? '', + }, +}); + +console.log('DSH_EXECUTOR_PLAN:'); +console.log(JSON.stringify({ + ok: plan.ok, + executor: plan.executor, + command: plan.command, + argsPreview: plan.args?.slice?.(0, 4), + cwd: plan.cwd, + message: plan.message ?? null, +}, null, 2)); + +if (!plan.ok) { + console.error('DSH_EXECUTOR_FAIL: launch plan unavailable'); + process.exit(1); +} + +console.log('DSH_EXECUTOR_OK: dry-run plan ready (use tool-gateway dry-run to execute)'); diff --git a/scripts/check-headroom-proxy-local.mjs b/scripts/check-headroom-proxy-local.mjs index 97b9683..ac7cfaa 100644 --- a/scripts/check-headroom-proxy-local.mjs +++ b/scripts/check-headroom-proxy-local.mjs @@ -4,7 +4,7 @@ * Requires: headroom proxy running with upstream pointing at DeepSeek compat proxy. * * Example: - * OPENAI_BASE_URL=http://127.0.0.1:18036/v1 HEADROOM_OUTPUT_SHAPER=0 headroom proxy --port 8787 + * OPENAI_TARGET_API_URL=http://127.0.0.1:18036/v1 HEADROOM_OUTPUT_SHAPER=0 headroom proxy --port 8787 */ import { buildHeadroomRunObservation, diff --git a/scripts/run-headroom-active-loopback.mjs b/scripts/run-headroom-active-loopback.mjs new file mode 100644 index 0000000..b04ea29 --- /dev/null +++ b/scripts/run-headroom-active-loopback.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Compare direct compat proxy vs headroom proxy on a tool-heavy request. + * Loopback only; does not mutate goosed config unless MEMIND_HEADROOM_APPLY_GOosed=1. + */ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { prepareGooseV149CheckEnv } from './goose-v149-canary.mjs'; +import { + resolveDeepseekNoThinkProxyBaseUrl, +} from '../deepseek-no-think-proxy.mjs'; +import { + probeHeadroomProxyReachable, + resolveGoosedApiUrlWithHeadroom, + resolveHeadroomMode, + resolveHeadroomProxyBaseUrl, +} from '../memind-headroom-policy.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +prepareGooseV149CheckEnv(process.env, root); + +const upstreamBase = resolveDeepseekNoThinkProxyBaseUrl(); +const headroomBase = resolveHeadroomProxyBaseUrl(); +const model = process.env.MEMIND_HEADROOM_OBS_MODEL ?? 'deepseek-chat'; +const apiKey = process.env.DEEPSEEK_API_KEY + ?? process.env.OPENAI_API_KEY + ?? process.env.TKMIND_EXECUTOR_API_KEY + ?? 'local-dev'; + +function buildToolHeavyMessages() { + const toolPayload = [ + 'TKMind search result chunk', + ...Array.from({ length: 400 }, (_, index) => ( + `line-${index}: portal resume smoke validates session round-trip with harness bootstrap` + )), + ].join('\n'); + + return [ + { role: 'system', content: 'You compress long tool output into one short sentence.' }, + { role: 'user', content: 'Summarize the tool result in under 30 Chinese characters.' }, + { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_headroom_probe', + type: 'function', + function: { name: 'tkmind_read', arguments: '{"path":"docs/plan.md"}' }, + }], + }, + { role: 'tool', tool_call_id: 'call_headroom_probe', content: toolPayload }, + ]; +} + +async function chatCompletion(baseUrl, messages, { viaHeadroom = false } = {}) { + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }; + if (viaHeadroom) { + headers['x-headroom-base-url'] = upstreamBase; + } + const response = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + messages, + stream: false, + max_tokens: 64, + temperature: 0, + }), + }); + const text = await response.text(); + let payload = {}; + try { + payload = JSON.parse(text); + } catch { + payload = { raw: text.slice(0, 400) }; + } + if (!response.ok) { + throw new Error(`${baseUrl} ${response.status}: ${text.slice(0, 400)}`); + } + const usage = payload?.usage ?? {}; + return { + reply: payload?.choices?.[0]?.message?.content ?? '', + promptTokens: Number(usage.prompt_tokens ?? usage.input_tokens ?? 0), + completionTokens: Number(usage.completion_tokens ?? usage.output_tokens ?? 0), + totalTokens: Number(usage.total_tokens ?? 0), + }; +} + +async function ensureHeadroomProxy() { + if (await probeHeadroomProxyReachable({ baseUrl: headroomBase })) return false; + const port = new URL(`${headroomBase}/`).port || '8787'; + console.log(`HEADROOM_ACTIVE_START: spawning headroom proxy on :${port}`); + const child = spawn( + 'headroom', + ['proxy', '--port', port, '--openai-api-url', upstreamBase.replace(/\/v1\/?$/, '')], + { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + OPENAI_TARGET_API_URL: upstreamBase, + HEADROOM_OUTPUT_SHAPER: '0', + }, + }, + ); + child.unref(); + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (await probeHeadroomProxyReachable({ baseUrl: headroomBase })) return true; + } + throw new Error('headroom proxy failed to become reachable'); +} + +async function main() { + const previousMode = resolveHeadroomMode(); + process.env.MEMIND_HEADROOM_MODE = 'active'; + await ensureHeadroomProxy(); + + const reachable = await probeHeadroomProxyReachable({ baseUrl: headroomBase }); + if (!reachable) { + throw new Error(`headroom proxy unreachable at ${headroomBase}`); + } + + const routing = resolveGoosedApiUrlWithHeadroom({ + apiUrl: upstreamBase, + mode: 'active', + headroomReachable: true, + eligible: true, + }); + if (!routing.routed) { + throw new Error(`headroom routing not active: ${JSON.stringify(routing)}`); + } + + const messages = buildToolHeavyMessages(); + const direct = await chatCompletion(upstreamBase, messages); + const viaHeadroom = await chatCompletion(headroomBase, messages, { viaHeadroom: true }); + + const savedPromptTokens = direct.promptTokens - viaHeadroom.promptTokens; + const savedRatio = direct.promptTokens > 0 + ? savedPromptTokens / direct.promptTokens + : 0; + + console.log('HEADROOM_ACTIVE_OBSERVATION:'); + console.log(` mode=${previousMode}->active`); + console.log(` upstream=${upstreamBase}`); + console.log(` headroom=${headroomBase}`); + console.log(` model=${model}`); + console.log(` direct_prompt_tokens=${direct.promptTokens}`); + console.log(` headroom_prompt_tokens=${viaHeadroom.promptTokens}`); + console.log(` saved_prompt_tokens=${savedPromptTokens}`); + console.log(` saved_prompt_ratio=${savedRatio.toFixed(4)}`); + console.log(` direct_reply=${JSON.stringify(direct.reply).slice(0, 120)}`); + console.log(` headroom_reply=${JSON.stringify(viaHeadroom.reply).slice(0, 120)}`); + + if (viaHeadroom.promptTokens >= direct.promptTokens) { + console.warn('HEADROOM_ACTIVE_WARN: no prompt token reduction observed on this sample'); + } else { + console.log('HEADROOM_ACTIVE_OK: prompt tokens reduced through headroom proxy'); + } +} + +main().catch((error) => { + console.error(`HEADROOM_ACTIVE_FAIL: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/start-headroom-proxy-local.mjs b/scripts/start-headroom-proxy-local.mjs index ed1bc5c..b4cf1e2 100644 --- a/scripts/start-headroom-proxy-local.mjs +++ b/scripts/start-headroom-proxy-local.mjs @@ -45,7 +45,7 @@ async function main() { stdio: 'inherit', env: { ...process.env, - OPENAI_BASE_URL: upstream, + OPENAI_TARGET_API_URL: upstream, HEADROOM_OUTPUT_SHAPER: '0', MEMIND_HEADROOM_MODE: process.env.MEMIND_HEADROOM_MODE ?? 'shadow', }, diff --git a/tool-gateway.mjs b/tool-gateway.mjs index 8fd41e5..f6f8820 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events'; import fs from 'node:fs/promises'; import path from 'node:path'; import { buildCursorExecutorLaunchPlan } from './cursor-agent-launch.mjs'; +import { dshExecutorEnabled } from './dsh-agent-launch.mjs'; import { extractCursorAgentDisplayText, parseCursorAgentUsage, @@ -22,9 +23,10 @@ function cursorExecutorEnabled(env = process.env) { } function codeExecutorsForEnv(env = process.env) { - return cursorExecutorEnabled(env) - ? ['cursor', ...BASE_CODE_EXECUTORS] - : [...BASE_CODE_EXECUTORS]; + const executors = [...BASE_CODE_EXECUTORS]; + if (dshExecutorEnabled(env)) executors.unshift('dsh'); + if (cursorExecutorEnabled(env)) executors.unshift('cursor'); + return executors; } const DEFAULT_STDIO_LIMIT = 64 * 1024; @@ -183,6 +185,11 @@ export function createToolGateway({ env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex', ); + const dshEnabled = dshExecutorEnabled(env); + const dshTaskTypes = csvSet( + env.MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES + ?? 'repo_refactor,multi_file', + ); const stdioLimit = positiveInteger(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT); function getStatus() { @@ -193,7 +200,9 @@ export function createToolGateway({ executors: codeExecutorsForEnv(env), defaultExecutor, cursorEnabled, + dshEnabled, cursorTaskTypes: [...cursorTaskTypes], + dshTaskTypes: [...dshTaskTypes], openhandsTaskTypes: [...openhandsTaskTypes], }; } @@ -209,6 +218,7 @@ export function createToolGateway({ if (requested) return requested; const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? '').trim().toLowerCase(); if (cursorEnabled && cursorTaskTypes.has(normalizedTaskType)) return 'cursor'; + if (dshEnabled && dshTaskTypes.has(normalizedTaskType)) return 'dsh'; if (openhandsTaskTypes.has(normalizedTaskType)) return 'openhands'; return defaultExecutor; } @@ -259,6 +269,17 @@ export function createToolGateway({ instruction: executorInstruction, env, }); + } else if (executor === 'dsh') { + if (!llmProviderService?.getExecutorLaunchPlan) { + throw new Error('Tool Gateway missing llm provider service'); + } + plan = await llmProviderService.getExecutorLaunchPlan('dsh', { + cwd, + mode: 'headless', + instruction: executorInstruction, + purpose: 'default', + includeSecret: true, + }); } else { if (!llmProviderService?.getExecutorLaunchPlan) { throw new Error('Tool Gateway missing llm provider service'); diff --git a/tool-gateway.test.mjs b/tool-gateway.test.mjs index b94c4c8..f991da5 100644 --- a/tool-gateway.test.mjs +++ b/tool-gateway.test.mjs @@ -33,7 +33,9 @@ test('tool gateway is disabled by default and reports protocol', () => { executors: ['aider', 'openhands'], defaultExecutor: 'aider', cursorEnabled: false, + dshEnabled: false, cursorTaskTypes: ['h5_chat_code_task', 'mindspace_page', 'mindspace_html_page'], + dshTaskTypes: ['repo_refactor', 'multi_file'], openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo', 'page_data_dev_complex'], }); }); @@ -194,6 +196,56 @@ test('tool gateway dry run builds executor launch plan without spawning', async assert.equal(plans[0].options.instruction, 'fix it'); }); +test('tool gateway exposes dsh executor when enabled', () => { + const gateway = createToolGateway({ + env: { + MEMIND_TOOL_GATEWAY_DSH_ENABLED: '1', + MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES: 'repo_refactor', + }, + }); + assert.deepEqual(gateway.getStatus().executors, ['dsh', 'aider', 'openhands']); + assert.equal(gateway.selectExecutor({ taskType: 'repo_refactor' }), 'dsh'); + assert.equal(gateway.selectExecutor({ taskType: 'small_patch' }), 'aider'); +}); + +test('tool gateway dsh dry run uses llm provider launch plan', async () => { + const plans = []; + const gateway = createToolGateway({ + env: { + MEMIND_TOOL_GATEWAY_ENABLED: '1', + MEMIND_TOOL_GATEWAY_DRY_RUN: '1', + MEMIND_TOOL_GATEWAY_DSH_ENABLED: '1', + MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES: 'repo_refactor', + }, + llmProviderService: { + async getExecutorLaunchPlan(executor, options) { + plans.push({ executor, options }); + return { + ok: true, + executor, + cwd: options.cwd, + command: 'npx', + args: ['-y', '@deepseek-ai/dsh', '--profile', 'headless', options.instruction], + }; + }, + }, + }); + + const result = await gateway.executeJob({ + runId: 'run-dsh', + requestId: 'req-dsh', + userId: 'user-1', + cwd: '/tmp/work', + taskType: 'repo_refactor', + userMessage: { content: [{ type: 'text', text: 'refactor auth module' }] }, + }); + + assert.equal(result.dryRun, true); + assert.equal(result.executor, 'dsh'); + assert.equal(plans.length, 1); + assert.equal(plans[0].executor, 'dsh'); +}); + test('tool gateway cursor dry run does not require llm provider service', async () => { const gateway = createToolGateway({ env: {