@anionex/dsh-vision-toolkit 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/exposure.ts CHANGED
@@ -19,7 +19,7 @@ export const VISION_TOOLKIT_ACTIVATE = 'vision_toolkit_activate'
19
19
 
20
20
  interface AgentExposure {
21
21
  active: boolean
22
- hideActivation?: () => void
22
+ liftRestriction?: () => void
23
23
  toolDisposers: Array<() => void>
24
24
  toolNames: string[]
25
25
  }
@@ -150,6 +150,9 @@ export class VisionToolExposure {
150
150
  const listeners = [
151
151
  this.ctx.on('agent/created', ({ agent }) => { this.attach(agent) }),
152
152
  this.ctx.on('agent/disposed', ({ agent }) => { this.detach(agent) }),
153
+ this.ctx.on('session/event', (session, event) => {
154
+ if (event.type === 'step/end') this.applyHideActivationForSession(session)
155
+ }),
153
156
  this.ctx.on('tools/result', (exec, result) => {
154
157
  if (result.isError === false
155
158
  && exec.name === 'skill'
@@ -192,23 +195,42 @@ export class VisionToolExposure {
192
195
 
193
196
  const definitions = this.createTools()
194
197
  const toolDisposers: Array<() => void> = []
195
- let hideActivation: (() => void) | undefined
196
198
  try {
197
199
  for (const definition of definitions) toolDisposers.push(agent.ctx.tools.register(definition))
198
- hideActivation = agent.ctx.tools.restrict({ deny: [VISION_TOOLKIT_ACTIVATE] })
200
+ // A Skill call and the bootstrap can be issued in the same model step.
201
+ // Restricting immediately would turn the still-in-flight bootstrap call
202
+ // into an UNKNOWN_TOOL error, so live sessions hide at step/end.
203
+ if (!this.isLiveSession(agent.session)) this.applyHideActivation(agent)
199
204
  } catch (error) {
200
- hideActivation?.()
201
205
  for (const dispose of toolDisposers.reverse()) dispose()
202
206
  throw error
203
207
  }
204
208
 
205
209
  state.active = true
206
- state.hideActivation = hideActivation
207
210
  state.toolDisposers = toolDisposers
208
211
  state.toolNames = definitions.map(definition => definition.name)
209
212
  return { activated: true, tools: [...state.toolNames] }
210
213
  }
211
214
 
215
+ /** Whether the session is attached to the live SessionStore (production). */
216
+ private isLiveSession(session: Session): boolean {
217
+ return this.ctx.sessions.get(session.id) === session
218
+ }
219
+
220
+ private applyHideActivationForSession(session: Session): void {
221
+ for (const agent of this.ctx.agents.list()) {
222
+ if (agent.session === session && this.states.get(agent)?.active === true) {
223
+ this.applyHideActivation(agent)
224
+ }
225
+ }
226
+ }
227
+
228
+ private applyHideActivation(agent: Agent): void {
229
+ const state = this.states.get(agent)
230
+ if (state === undefined || state.liftRestriction !== undefined) return
231
+ state.liftRestriction = agent.ctx.tools.restrict({ deny: [VISION_TOOLKIT_ACTIVATE] })
232
+ }
233
+
212
234
  private detach(agent: Agent): void {
213
235
  const state = this.states.get(agent)
214
236
  if (state === undefined) return
@@ -222,7 +244,7 @@ export class VisionToolExposure {
222
244
  }
223
245
 
224
246
  private disposeState(state: AgentExposure): void {
225
- state.hideActivation?.()
247
+ state.liftRestriction?.()
226
248
  for (const dispose of state.toolDisposers.reverse()) dispose()
227
249
  }
228
250
  }
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { createHash, randomUUID } from 'node:crypto'
10
10
  import {
11
+ access,
12
+ chmod,
11
13
  lstat,
12
14
  mkdir,
13
15
  mkdtemp,
@@ -20,10 +22,16 @@ import {
20
22
  utimes,
21
23
  writeFile,
22
24
  } from 'node:fs/promises'
25
+ import { createWriteStream } from 'node:fs'
23
26
  import { homedir } from 'node:os'
24
27
  import { dirname, join, resolve } from 'node:path'
28
+ import { Transform } from 'node:stream'
29
+ import { pipeline } from 'node:stream/promises'
25
30
  import { fileURLToPath } from 'node:url'
26
31
  import type { Context } from '@deepseek-ai/cordis'
32
+ import { x as extractTar } from 'tar'
33
+ import { EnvHttpProxyAgent } from 'undici'
34
+ import { request as undiciRequest } from 'undici'
27
35
  import type { ResolvedVisionToolkitConfig } from './config.ts'
28
36
  import { VisionToolkitError } from './errors.ts'
29
37
  import { UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
@@ -252,10 +260,317 @@ async function pythonMetadata(
252
260
  }
253
261
  }
254
262
 
255
- async function resolveBootstrapPython(
263
+ interface PythonBootstrapArtifact {
264
+ url: string
265
+ sha256: string
266
+ size: number
267
+ }
268
+
269
+ interface PythonBootstrapManifest {
270
+ schemaVersion: 1
271
+ pythonVersion: string
272
+ buildTag: string
273
+ artifacts: Record<string, PythonBootstrapArtifact>
274
+ }
275
+
276
+ const PYTHON_BOOTSTRAP_MANIFEST_PATH = join(PACKAGE_ROOT, 'assets', 'python-bootstrap.json')
277
+ const PYTHON_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000
278
+ const PYTHON_DOWNLOAD_ATTEMPTS = 3
279
+
280
+ async function readPythonBootstrapManifest(): Promise<PythonBootstrapManifest> {
281
+ let parsed: unknown
282
+ try {
283
+ parsed = JSON.parse(await readFile(PYTHON_BOOTSTRAP_MANIFEST_PATH, 'utf8'))
284
+ } catch (error) {
285
+ throw new VisionToolkitError('runtime', `python bootstrap manifest is unreadable: ${PYTHON_BOOTSTRAP_MANIFEST_PATH}`, { cause: error })
286
+ }
287
+ if (typeof parsed !== 'object' || parsed === null) {
288
+ throw new VisionToolkitError('runtime', 'python bootstrap manifest is not an object')
289
+ }
290
+ const manifest = parsed as Partial<PythonBootstrapManifest>
291
+ if (
292
+ manifest.schemaVersion !== 1
293
+ || typeof manifest.pythonVersion !== 'string'
294
+ || !/^3\.\d+\.\d+$/u.test(manifest.pythonVersion)
295
+ || typeof manifest.buildTag !== 'string'
296
+ || !/^\d{8}$/u.test(manifest.buildTag)
297
+ || typeof manifest.artifacts !== 'object'
298
+ || manifest.artifacts === null
299
+ ) {
300
+ throw new VisionToolkitError('runtime', 'python bootstrap manifest is invalid')
301
+ }
302
+ for (const [target, artifact] of Object.entries(manifest.artifacts)) {
303
+ if (
304
+ typeof artifact !== 'object'
305
+ || artifact === null
306
+ || typeof artifact.url !== 'string'
307
+ || !artifact.url.startsWith('https://github.com/astral-sh/python-build-standalone/releases/download/')
308
+ || typeof artifact.sha256 !== 'string'
309
+ || !/^[a-f0-9]{64}$/u.test(artifact.sha256)
310
+ || !Number.isInteger(artifact.size)
311
+ || artifact.size <= 0
312
+ ) {
313
+ throw new VisionToolkitError('runtime', `python bootstrap manifest has an invalid artifact: ${target}`)
314
+ }
315
+ }
316
+ return manifest as PythonBootstrapManifest
317
+ }
318
+
319
+ /** Map Node platform/arch to the pinned artifact name, including musl Linux. */
320
+ export function pythonBootstrapTarget(platform: string, arch: string, musl: boolean): string {
321
+ return platform === 'linux' && musl ? `${platform}-${arch}-musl` : `${platform}-${arch}`
322
+ }
323
+
324
+ async function runningMusl(): Promise<boolean> {
325
+ if (process.platform !== 'linux') return false
326
+ try {
327
+ await access(join('/', 'etc', 'alpine-release'))
328
+ return true
329
+ } catch {
330
+ // Fall through to the loader-name probe.
331
+ }
332
+ const loader = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
333
+ try {
334
+ await access(join('/', 'lib', `ld-musl-${loader}.so.1`))
335
+ return true
336
+ } catch {
337
+ return false
338
+ }
339
+ }
340
+
341
+ interface DownloadResponse {
342
+ statusCode: number
343
+ headers: Record<string, string | string[] | undefined>
344
+ body: NodeJS.ReadableStream
345
+ close: () => Promise<void>
346
+ }
347
+
348
+ type DownloadRequest = (url: string, signal: AbortSignal) => Promise<DownloadResponse>
349
+
350
+ const DOWNLOAD_HOSTS = new Set([
351
+ 'github.com',
352
+ 'objects.githubusercontent.com',
353
+ 'release-assets.githubusercontent.com',
354
+ ])
355
+
356
+ async function defaultDownloadRequest(url: string, signal: AbortSignal): Promise<DownloadResponse> {
357
+ const dispatcher = new EnvHttpProxyAgent()
358
+ let current = url
359
+ try {
360
+ for (let redirects = 0; ; redirects++) {
361
+ const host = new URL(current).hostname
362
+ if (!DOWNLOAD_HOSTS.has(host)) throw new Error(`download redirected outside GitHub: ${host}`)
363
+ const response = await undiciRequest(current, {
364
+ dispatcher,
365
+ signal,
366
+ headers: { 'user-agent': 'dsh-vision-toolkit' },
367
+ })
368
+ if (response.statusCode >= 300 && response.statusCode < 400) {
369
+ const location = response.headers.location
370
+ await response.body.dump().catch(() => {})
371
+ if (typeof location !== 'string' || location.length === 0 || redirects >= 5) {
372
+ throw new Error('download redirected too many times or without a Location header')
373
+ }
374
+ current = new URL(location, current).toString()
375
+ continue
376
+ }
377
+ return {
378
+ statusCode: response.statusCode,
379
+ headers: response.headers,
380
+ body: response.body,
381
+ close: () => dispatcher.close().catch(() => {}),
382
+ }
383
+ }
384
+ } catch (error) {
385
+ await dispatcher.close().catch(() => {})
386
+ throw error
387
+ }
388
+ }
389
+
390
+ async function downloadBundledPythonOnce(
391
+ artifact: PythonBootstrapArtifact,
392
+ destination: string,
393
+ requestImpl: DownloadRequest = defaultDownloadRequest,
394
+ ): Promise<void> {
395
+ const signal = AbortSignal.timeout(PYTHON_DOWNLOAD_TIMEOUT_MS)
396
+ let response: DownloadResponse
397
+ try {
398
+ response = await requestImpl(artifact.url, signal)
399
+ } catch (error) {
400
+ throw new Error(`download request failed: ${error instanceof Error ? error.message : String(error)}`)
401
+ }
402
+ try {
403
+ if (response.statusCode !== 200) throw new Error(`download returned HTTP ${response.statusCode}`)
404
+ const hash = createHash('sha256')
405
+ let bytes = 0
406
+ const hasher = new Transform({
407
+ transform(chunk, _encoding, callback) {
408
+ hash.update(chunk as Buffer)
409
+ bytes += (chunk as Buffer).length
410
+ callback(null, chunk)
411
+ },
412
+ })
413
+ await pipeline(response.body, hasher, createWriteStream(destination))
414
+ if (bytes !== artifact.size) throw new Error(`size mismatch: expected ${artifact.size}, received ${bytes}`)
415
+ if (hash.digest('hex') !== artifact.sha256) throw new Error('sha256 mismatch')
416
+ } finally {
417
+ await response.close()
418
+ }
419
+ }
420
+
421
+ async function downloadBundledPython(
422
+ artifact: PythonBootstrapArtifact,
423
+ destination: string,
424
+ requestImpl: DownloadRequest,
425
+ ): Promise<void> {
426
+ let lastError: unknown
427
+ for (let attempt = 0; attempt < PYTHON_DOWNLOAD_ATTEMPTS; attempt++) {
428
+ try {
429
+ await downloadBundledPythonOnce(artifact, destination, requestImpl)
430
+ return
431
+ } catch (error) {
432
+ lastError = error
433
+ if (attempt + 1 < PYTHON_DOWNLOAD_ATTEMPTS) {
434
+ await new Promise(resolveWait => setTimeout(resolveWait, 500 * 2 ** attempt))
435
+ }
436
+ }
437
+ }
438
+ throw lastError
439
+ }
440
+
441
+ /**
442
+ * Cross-process directory lock with a stale-lock timeout and heartbeat,
443
+ * matching the managed-runtime lock semantics.
444
+ */
445
+ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
446
+ const owner = randomUUID()
447
+ let acquired = false
448
+ await mkdir(dirname(lockPath), { recursive: true })
449
+ try {
450
+ await mkdir(lockPath, { recursive: false })
451
+ acquired = true
452
+ await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
453
+ } catch (error) {
454
+ if (acquired) {
455
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {})
456
+ throw error
457
+ }
458
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
459
+ const started = Date.now()
460
+ while (Date.now() - started < PREPARE_TIMEOUT_MS) {
461
+ try {
462
+ const info = await stat(lockPath)
463
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
464
+ await rm(lockPath, { recursive: true, force: true })
465
+ return withDirectoryLock(lockPath, fn)
466
+ }
467
+ } catch {
468
+ return withDirectoryLock(lockPath, fn)
469
+ }
470
+ await new Promise(resolveWait => setTimeout(resolveWait, 250))
471
+ }
472
+ throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the bundled Python')
473
+ }
474
+ const heartbeat = setInterval(() => {
475
+ const now = new Date()
476
+ void utimes(lockPath, now, now).catch(() => {})
477
+ }, LOCK_HEARTBEAT_MS)
478
+ heartbeat.unref()
479
+ try {
480
+ return await fn()
481
+ } finally {
482
+ clearInterval(heartbeat)
483
+ try {
484
+ if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
485
+ await rm(lockPath, { recursive: true, force: true })
486
+ }
487
+ } catch {
488
+ // The lock was already removed or replaced.
489
+ }
490
+ }
491
+ }
492
+
493
+ export async function acquireBundledPython(
494
+ ctx: Context,
495
+ stateRoot: string,
496
+ cwd: string,
497
+ manifestOverride?: PythonBootstrapManifest,
498
+ requestImpl?: DownloadRequest,
499
+ ): Promise<{ command: RuntimeCommand; version: string }> {
500
+ const manifest = manifestOverride ?? await readPythonBootstrapManifest()
501
+ const target = pythonBootstrapTarget(process.platform, process.arch, await runningMusl())
502
+ const artifact = manifest.artifacts[target]
503
+ if (artifact === undefined) {
504
+ throw new VisionToolkitError(
505
+ 'runtime',
506
+ `no bundled Python ${manifest.pythonVersion} artifact for ${target}; install Python 3.11+ or configure runtime.python`,
507
+ )
508
+ }
509
+ const root = join(stateRoot, 'python-bootstrap', `${manifest.pythonVersion}-${target}`)
510
+ const interpreter = process.platform === 'win32' ? join(root, 'python.exe') : join(root, 'bin', 'python3')
511
+ const command: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
512
+ const cached = await pythonMetadata(ctx, command, cwd)
513
+ if (cached !== undefined) return { command, version: cached.version }
514
+ await withDirectoryLock(`${root}.lock`, async () => {
515
+ const ready = await pythonMetadata(ctx, command, cwd)
516
+ if (ready !== undefined) return
517
+ const parent = dirname(root)
518
+ await mkdir(parent, { recursive: true })
519
+ await rm(root, { recursive: true, force: true })
520
+ const work = await mkdtemp(join(parent, '.python-bootstrap-'))
521
+ try {
522
+ const archive = join(work, 'python.tar.gz')
523
+ const extractDir = join(work, 'extract')
524
+ await mkdir(extractDir, { recursive: true })
525
+ try {
526
+ await downloadBundledPython(artifact, archive, requestImpl ?? defaultDownloadRequest)
527
+ } catch (error) {
528
+ throw new VisionToolkitError(
529
+ 'runtime',
530
+ `bundled Python ${manifest.pythonVersion} could not be downloaded for ${target} (${error instanceof Error ? error.message : String(error)}); install Python 3.11+ or configure runtime.python`,
531
+ { cause: error },
532
+ )
533
+ }
534
+ try {
535
+ await extractTar({ file: archive, cwd: extractDir, strip: 1 })
536
+ } catch (error) {
537
+ throw new VisionToolkitError(
538
+ 'runtime',
539
+ `bundled Python ${manifest.pythonVersion} could not be extracted for ${target}`,
540
+ { cause: error },
541
+ )
542
+ }
543
+ const extractedInterpreter = process.platform === 'win32'
544
+ ? join(extractDir, 'python.exe')
545
+ : join(extractDir, 'bin', 'python3')
546
+ try {
547
+ await access(extractedInterpreter)
548
+ } catch (error) {
549
+ throw new VisionToolkitError(
550
+ 'runtime',
551
+ `bundled Python ${manifest.pythonVersion} for ${target} is missing its interpreter`,
552
+ { cause: error },
553
+ )
554
+ }
555
+ if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
556
+ await rename(extractDir, root)
557
+ } finally {
558
+ await rm(work, { recursive: true, force: true })
559
+ }
560
+ })
561
+ const metadata = await pythonMetadata(ctx, command, cwd)
562
+ if (metadata === undefined) {
563
+ throw new VisionToolkitError('runtime', 'bundled Python did not start after extraction')
564
+ }
565
+ return { command, version: metadata.version }
566
+ }
567
+
568
+ export async function resolveBootstrapPython(
256
569
  ctx: Context,
257
570
  configured: string | undefined,
258
571
  cwd: string,
572
+ manifestOverride?: PythonBootstrapManifest,
573
+ requestImpl?: DownloadRequest,
259
574
  ): Promise<{ command: RuntimeCommand; version: string; major: number; minor: number }> {
260
575
  const candidates: RuntimeCommand[] = configured === undefined
261
576
  ? process.platform === 'win32'
@@ -275,12 +590,27 @@ async function resolveBootstrapPython(
275
590
  return { command, ...metadata }
276
591
  }
277
592
  }
278
- throw new VisionToolkitError(
279
- 'runtime',
280
- configured === undefined
281
- ? 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher'
282
- : `vision-toolkit requires Python 3.11 or newer: ${configured}`,
283
- )
593
+ if (configured !== undefined) {
594
+ throw new VisionToolkitError('runtime', `vision-toolkit requires Python 3.11 or newer: ${configured}`)
595
+ }
596
+ try {
597
+ const stateRoot = visionToolkitStateRoot()
598
+ await mkdir(stateRoot, { recursive: true })
599
+ const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
600
+ return {
601
+ command: bundled.command,
602
+ version: bundled.version,
603
+ major: Number.parseInt(bundled.version.split('.')[0] ?? '', 10),
604
+ minor: Number.parseInt(bundled.version.split('.')[1] ?? '', 10),
605
+ }
606
+ } catch (error) {
607
+ if (error instanceof VisionToolkitError) throw error
608
+ throw new VisionToolkitError(
609
+ 'runtime',
610
+ 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher, and automatic bundled-Python preparation failed',
611
+ { cause: error },
612
+ )
613
+ }
284
614
  }
285
615
 
286
616
  /** Persistent per-DSH-home cache root shared by runtime and Web support files. */
package/src/runtime.ts CHANGED
@@ -2082,8 +2082,13 @@ export class VisionToolkitRuntime {
2082
2082
  await response.body?.cancel().catch(() => {})
2083
2083
  if (response.ok) {
2084
2084
  service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` }
2085
- } else if (response.status === 401 || response.status === 403) {
2085
+ } else if (response.status === 401) {
2086
2086
  service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` }
2087
+ } else if (response.status === 403) {
2088
+ // Some providers (e.g. Groq preview/account restrictions) block GET /models
2089
+ // while real multimodal requests still work. Treat 403 as a warning so the
2090
+ // explicit vision-model test, not the model list endpoint, decides access.
2091
+ service = { status: 'warning', detail: `Service is reachable but restricted GET /models (HTTP 403); the credential may still be valid for real vision requests` }
2087
2092
  } else if (response.status === 404 || response.status === 405) {
2088
2093
  service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` }
2089
2094
  } else if (response.status === 429) {
package/src/skill.ts CHANGED
@@ -24,8 +24,8 @@ export const VISION_TOOLS_SKILL_CONTENT = readFileSync(
24
24
  /** Runtime skill registration mounted only after every native tool is ready. */
25
25
  export const VISION_TOOLS_SKILL: SkillRegistration = {
26
26
  name: VISION_TOOLS_SKILL_NAME,
27
- description: 'Native DSH visual engineering tools adapted from agent-vision-toolkit: vision_glance, vision_ground, vision_detect, vision_trace, vision_crop, vision_pixel_diff, vision_long_screenshot_ocr, vision_extract_foreground, vision_dominant_colors, vision_html_screenshot, and upstream playbooks.',
28
- whenToUse: 'Use whenever a task depends on image text/content, pixel coordinates, screenshot-to-UI reconstruction, visual regression, reusable image/SVG assets, diagram reconstruction, GUI operation from screenshots, or tall screenshot OCR.',
27
+ description: '把截图或设计图还原为 UI(网页、应用界面或组件),生成 HTML/CSS 或项目现有前端代码;也支持图片问答、长截图/聊天记录 OCR、元素定位与盘点、裁剪、前景提取、像素对比、取色、SVG 描摹和 HTML 转截图。当任务涉及图片理解、UI 还原/重建(rebuild or restore a UI from a screenshot)、视觉回归、像素坐标、基于截图的 GUI 操作、可复用图片/SVG 素材、图表还原或长截图 OCR 时使用。',
28
+ whenToUse: '任务依赖图片文字/内容、像素坐标、截图转 UI 重建、视觉回归、可复用图片/SVG 素材、图表还原、基于截图的 GUI 操作或长截图 OCR 时使用。',
29
29
  source: 'runtime',
30
30
  resourceBase: {
31
31
  kind: 'directory',