@mengruo/dsh-vision-toolkit 0.1.4 → 0.1.6-beta.0

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.
Files changed (74) hide show
  1. package/README.md +11 -70
  2. package/README.zh.md +11 -69
  3. package/assets/1.mp4 +0 -0
  4. package/assets/skill/SKILL.md +37 -5
  5. package/docs/plan-per-tool-visibility.md +82 -0
  6. package/lib/client.js +157 -6
  7. package/lib/client.js.map +1 -1
  8. package/lib/config.js +52 -0
  9. package/lib/config.js.map +1 -1
  10. package/lib/exposure.js +35 -8
  11. package/lib/exposure.js.map +1 -1
  12. package/lib/index.js +1 -1
  13. package/lib/index.js.map +1 -1
  14. package/lib/object-storage.js +141 -0
  15. package/lib/object-storage.js.map +1 -0
  16. package/lib/paths.js +9 -0
  17. package/lib/paths.js.map +1 -1
  18. package/lib/runtime.js +314 -27
  19. package/lib/runtime.js.map +1 -1
  20. package/lib/tools.js +116 -4
  21. package/lib/tools.js.map +1 -1
  22. package/lib/types/client/index.d.ts +62 -1
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/config.d.ts +59 -0
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/exposure.d.ts +14 -2
  27. package/lib/types/exposure.d.ts.map +1 -1
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/object-storage.d.ts +54 -0
  30. package/lib/types/object-storage.d.ts.map +1 -0
  31. package/lib/types/paths.d.ts +7 -0
  32. package/lib/types/paths.d.ts.map +1 -1
  33. package/lib/types/runtime.d.ts +50 -0
  34. package/lib/types/runtime.d.ts.map +1 -1
  35. package/lib/types/tools.d.ts +22 -1
  36. package/lib/types/tools.d.ts.map +1 -1
  37. package/lib/types/upstream.d.ts +1 -0
  38. package/lib/types/upstream.d.ts.map +1 -1
  39. package/lib/types/video.d.ts +78 -0
  40. package/lib/types/video.d.ts.map +1 -0
  41. package/lib/types/web.d.ts +8 -0
  42. package/lib/types/web.d.ts.map +1 -1
  43. package/lib/upstream.js +3 -0
  44. package/lib/upstream.js.map +1 -1
  45. package/lib/video.js +169 -0
  46. package/lib/video.js.map +1 -0
  47. package/lib/web.js +78 -6
  48. package/lib/web.js.map +1 -1
  49. package/package.json +4 -1
  50. package/src/client/index.tsx +222 -6
  51. package/src/config.ts +110 -0
  52. package/src/exposure.ts +34 -9
  53. package/src/index.ts +10 -5
  54. package/src/object-storage.ts +174 -0
  55. package/src/paths.ts +11 -0
  56. package/src/runtime.ts +332 -25
  57. package/src/tools.ts +144 -3
  58. package/src/upstream.ts +4 -0
  59. package/src/video.ts +222 -0
  60. package/src/web.ts +93 -7
  61. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
  62. package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
  63. package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
  64. package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
  65. package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
  66. package/vendor/agent-vision-toolkit/bin/glance +8 -1
  67. package/vendor/agent-vision-toolkit/detect.py +13 -7
  68. package/vendor/agent-vision-toolkit/ground.py +43 -18
  69. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
  70. package/vendor/agent-vision-toolkit/vision_client.py +84 -6
  71. package/assets/community-group-qr.png +0 -0
  72. package/assets/logo_aihubmix.png +0 -0
  73. package/assets/logo_eapi_dark.png +0 -0
  74. package/assets/wechat-reward.png +0 -0
package/src/runtime.ts CHANGED
@@ -18,6 +18,7 @@ import { isBuiltInFreeVisionProvider, type ResolvedProvider, type ResolvedVision
18
18
  import { BUILT_IN_FREE_VISION_KEY } from './defaults.ts'
19
19
  import { evidenceRuntimeFingerprint } from './evidence-cache.ts'
20
20
  import { VisionToolkitError, type VisionToolkitErrorCode } from './errors.ts'
21
+ import { ObjectStorageClient, splitObjectStorageCredential, type ObjectStorageSettings } from './object-storage.ts'
21
22
  import {
22
23
  assertDistinctOutput,
23
24
  commitStagedDirectory,
@@ -28,11 +29,23 @@ import {
28
29
  isWithin,
29
30
  resolveHtmlFile,
30
31
  resolveInputFile,
32
+ resolveInputVideo,
31
33
  resolveOutputDirectory,
32
34
  resolveOutputFile,
33
35
  seedStagedDirectory,
34
36
  type PathPolicy,
35
37
  } from './paths.ts'
38
+ import {
39
+ extractChatAnswer,
40
+ ffprobeBinaryPath,
41
+ parseFfprobeOutput,
42
+ videoMediaType,
43
+ type VideoInfo,
44
+ type VideoInfoRequest,
45
+ type VideoMetadata,
46
+ type VideoUnderstandRequest,
47
+ type VideoUnderstandResult,
48
+ } from './video.ts'
36
49
  import {
37
50
  parseCropOutput,
38
51
  parseDominantColorsOutput,
@@ -55,6 +68,8 @@ import { PLUGIN_VERSION } from './version.ts'
55
68
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
56
69
  const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url))
57
70
  const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
71
+ const VISION_MODEL_TEST_VIDEO = fileURLToPath(new URL('../assets/1.mp4', import.meta.url))
72
+ const VISION_MODEL_TEST_VIDEO_PROMPT = '这个视频内容是什么?'
58
73
 
59
74
  /** Bump when the Pillow compression ladder changes so stale cache entries are ignored. */
60
75
  const COMPRESSED_IMAGE_CACHE_VERSION = 'v2'
@@ -540,6 +555,17 @@ const FORMAT_BY_EXTENSION = new Map([
540
555
  ])
541
556
  const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/
542
557
 
558
+ /** MIME type for one analyzed image format, used when uploading to object storage. */
559
+ function imageMimeType(format: string): string {
560
+ switch (format) {
561
+ case 'png': return 'image/png'
562
+ case 'jpeg': return 'image/jpeg'
563
+ case 'gif': return 'image/gif'
564
+ case 'webp': return 'image/webp'
565
+ default: return 'application/octet-stream'
566
+ }
567
+ }
568
+
543
569
  /**
544
570
  * Error codes a provider retries against the SAME provider within its
545
571
  * `attempts` budget. Only transient failures are worth re-requesting: a
@@ -801,6 +827,17 @@ export class VisionToolkitRuntime {
801
827
  return this.config.storageDir
802
828
  }
803
829
 
830
+ /**
831
+ * Whether at least one enabled OpenAI-compatible provider has video support
832
+ * turned on. A capability probe only: tool visibility is governed by the
833
+ * Settings tool-visibility video bucket, not by this flag. `videoUnderstand`
834
+ * re-checks it at call time and reports "video understanding unavailable"
835
+ * when it is false.
836
+ */
837
+ get videoSupportEnabled(): boolean {
838
+ return this.videoProvider() !== undefined
839
+ }
840
+
804
841
  /** Stable identity for persisted image descriptions produced by this runtime. */
805
842
  get evidenceFingerprint(): string {
806
843
  return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
@@ -958,6 +995,11 @@ export class VisionToolkitRuntime {
958
995
  return this.config.providers.find(provider => provider.enabled) ?? this.config.providers[0]!
959
996
  }
960
997
 
998
+ /** Highest-priority enabled OpenAI provider with video support enabled. */
999
+ private videoProvider(): ResolvedProvider | undefined {
1000
+ return this.config.providers.find(provider => provider.enabled && provider.videoSupport && provider.protocol === 'openai')
1001
+ }
1002
+
961
1003
  /** Build the upstream environment for one resolved provider. */
962
1004
  private providerEnv(provider: ResolvedProvider, resolved: ResolvedCredential): UpstreamEnvironment {
963
1005
  const sslVerify = process.env.VISION_SSL_VERIFY?.trim()
@@ -968,6 +1010,7 @@ export class VisionToolkitRuntime {
968
1010
  VISION_API_PROTOCOL: provider.protocol === 'anthropic' ? 'anthropic' : 'chat_completions',
969
1011
  VISION_ANTHROPIC_THINKING: provider.anthropicThinking,
970
1012
  ...(sslVerify === undefined ? {} : { VISION_SSL_VERIFY: sslVerify }),
1013
+ ...(provider.stream ? { VISION_STREAM: '1' } : {}),
971
1014
  VISION_USER_AGENT: provider.userAgent,
972
1015
  LANG: this.config.language,
973
1016
  }
@@ -984,6 +1027,9 @@ export class VisionToolkitRuntime {
984
1027
  protocol: provider.protocol,
985
1028
  anthropicThinking: provider.anthropicThinking,
986
1029
  userAgent: provider.userAgent,
1030
+ stream: provider.stream,
1031
+ uploadViaUrl: provider.uploadViaUrl,
1032
+ videoSupport: provider.videoSupport,
987
1033
  })
988
1034
  ? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
989
1035
  : await this.ctx.credentials.resolve(provider.credential)
@@ -1253,6 +1299,249 @@ export class VisionToolkitRuntime {
1253
1299
  operation.metrics.imagePixels += image.width * image.height
1254
1300
  }
1255
1301
 
1302
+ /** Resolve the configured object storage into a usable client, or undefined. */
1303
+ private async resolveObjectStorageClient(): Promise<ObjectStorageClient | undefined> {
1304
+ const objectStorage = this.config.objectStorage
1305
+ if (objectStorage.credential === undefined || objectStorage.endpoint.length === 0 || objectStorage.bucket.length === 0) {
1306
+ return undefined
1307
+ }
1308
+ let resolved: ResolvedCredential | undefined
1309
+ try {
1310
+ resolved = await this.ctx.credentials.resolve(objectStorage.credential)
1311
+ } catch {
1312
+ resolved = undefined
1313
+ }
1314
+ if (resolved === undefined) return undefined
1315
+ let accessKeyId: string
1316
+ let secretAccessKey: string
1317
+ try {
1318
+ const split = splitObjectStorageCredential(resolved.value)
1319
+ accessKeyId = split.accessKeyId
1320
+ secretAccessKey = split.secretAccessKey
1321
+ } catch (error) {
1322
+ throw new VisionToolkitError('config', 'object storage credential is malformed', { cause: error })
1323
+ }
1324
+ const settings: ObjectStorageSettings = {
1325
+ endpoint: objectStorage.endpoint,
1326
+ bucket: objectStorage.bucket,
1327
+ accessKeyId,
1328
+ secretAccessKey,
1329
+ ...(objectStorage.publicBase === undefined ? {} : { publicBase: objectStorage.publicBase }),
1330
+ }
1331
+ return new ObjectStorageClient(settings)
1332
+ }
1333
+
1334
+ /**
1335
+ * Upload the prepared images to object storage and return their URLs plus a
1336
+ * cleanup callback, when the primary provider opts into URL transfer and
1337
+ * object storage is configured. Returns undefined otherwise (base64 path).
1338
+ */
1339
+ private async maybeTransferImages(
1340
+ pool: readonly ResolvedProviderEnv[],
1341
+ images: readonly ImageInfo[],
1342
+ operation: OperationContext,
1343
+ ): Promise<{ urls: string[]; cleanup: () => Promise<void> } | undefined> {
1344
+ const primary = pool[0]
1345
+ if (primary === undefined || primary.provider.uploadViaUrl !== true) return undefined
1346
+ const client = await this.resolveObjectStorageClient()
1347
+ if (client === undefined) {
1348
+ throw new VisionToolkitError('config', 'uploadViaUrl is enabled but object storage is not configured')
1349
+ }
1350
+ const keys: string[] = []
1351
+ const urls: string[] = []
1352
+ try {
1353
+ for (const image of images) {
1354
+ if (operation.signal.aborted) throw new VisionToolkitError('cancelled', 'vision image upload cancelled')
1355
+ const uploaded = await client.uploadImage(image.path, imageMimeType(image.format))
1356
+ keys.push(uploaded.key)
1357
+ urls.push(uploaded.url)
1358
+ }
1359
+ } catch (error) {
1360
+ await Promise.allSettled(keys.map(key => client.deleteObject(key)))
1361
+ throw error
1362
+ }
1363
+ return {
1364
+ urls,
1365
+ cleanup: async () => {
1366
+ await Promise.allSettled(keys.map(key => client.deleteObject(key)))
1367
+ },
1368
+ }
1369
+ }
1370
+
1371
+ /** Settings "test storage" probe: upload → head → delete a tiny marker object. */
1372
+ async testObjectStorage(): Promise<{ detail: string }> {
1373
+ const client = await this.resolveObjectStorageClient()
1374
+ if (client === undefined) {
1375
+ throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
1376
+ }
1377
+ return client.test()
1378
+ }
1379
+
1380
+ /**
1381
+ * Settings "test video call" probe: upload the bundled diagnostic video to
1382
+ * object storage and send one OpenAI-compatible (Aliyun Qwen) video request.
1383
+ * The wire content uses the Qwen video block shape (`video_url` + `fps`) plus
1384
+ * the fixed test question. The object is deleted afterwards, best-effort.
1385
+ */
1386
+ async testVideoCall(options: ToolCallOptions, provider?: ResolvedProvider): Promise<{ detail: string }> {
1387
+ return this.runOperation('vision_toolkit_video_test', options, async (operation) => {
1388
+ const target = provider ?? this.primaryProvider
1389
+ if (target.protocol !== 'openai') {
1390
+ throw new VisionToolkitError('config', 'video support is only available for OpenAI-compatible providers')
1391
+ }
1392
+ const entry = await this.resolveProviderEnv(target)
1393
+ if (entry === undefined) {
1394
+ throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`)
1395
+ }
1396
+ const client = await this.resolveObjectStorageClient()
1397
+ if (client === undefined) {
1398
+ throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
1399
+ }
1400
+ const uploaded = await client.uploadImage(VISION_MODEL_TEST_VIDEO, 'video/mp4')
1401
+ try {
1402
+ const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, VISION_MODEL_TEST_VIDEO_PROMPT, 2, operation)
1403
+ const snippet = answer.trim().slice(0, 160)
1404
+ return {
1405
+ detail: snippet.length === 0
1406
+ ? `Video call completed against ${target.model}`
1407
+ : `Video call completed against ${target.model}: ${snippet}`,
1408
+ }
1409
+ } finally {
1410
+ await client.deleteObject(uploaded.key)
1411
+ }
1412
+ })
1413
+ }
1414
+
1415
+ /** Run the bundled ffprobe binary once and parse its JSON metadata. */
1416
+ private async runFfprobe(videoPath: string, operation: OperationContext): Promise<VideoMetadata> {
1417
+ const binary = ffprobeBinaryPath()
1418
+ if (binary === null) {
1419
+ throw new VisionToolkitError('runtime', 'ffprobe is unavailable; the ffprobe-static package must be installed alongside the plugin')
1420
+ }
1421
+ const handle = this.ctx.subprocess.spawn({
1422
+ argv: [binary, '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', videoPath],
1423
+ cwd: process.cwd(),
1424
+ stdio: {
1425
+ stdin: 'ignore',
1426
+ stdout: { maxBytes: 512 * 1024 },
1427
+ stderr: { maxBytes: 64 * 1024 },
1428
+ },
1429
+ graceMs: 2000,
1430
+ signal: operation.signal,
1431
+ })
1432
+ const outcome = await handle.done
1433
+ const stdout = handle.collected.stdout?.readFrom(0)
1434
+ const stderr = handle.collected.stderr?.readFrom(0)
1435
+ if (outcome.exitCode !== 0) {
1436
+ throw new VisionToolkitError('input', `cannot read video metadata: ${(stderr?.text ?? '').trim() || 'ffprobe failed'}`)
1437
+ }
1438
+ if (stdout?.lossy === true) {
1439
+ throw new VisionToolkitError('output', 'video metadata output exceeded the capture limit')
1440
+ }
1441
+ try {
1442
+ return parseFfprobeOutput(stdout?.text ?? '')
1443
+ } catch (error) {
1444
+ throw new VisionToolkitError('output', 'video metadata output is invalid', { cause: error })
1445
+ }
1446
+ }
1447
+
1448
+ /**
1449
+ * One OpenAI-compatible (Aliyun Qwen) video chat-completions request. The
1450
+ * content array carries the video_url block (with `fps`) plus the prompt text.
1451
+ */
1452
+ private async requestVideoAnswer(
1453
+ target: ResolvedProvider,
1454
+ apiKey: string,
1455
+ videoUrl: string,
1456
+ prompt: string,
1457
+ fps: number,
1458
+ operation: OperationContext,
1459
+ ): Promise<string> {
1460
+ operation.metrics.usedVisionService = true
1461
+ const started = Date.now()
1462
+ const response = await fetch(`${target.baseUrl}/chat/completions`, {
1463
+ method: 'POST',
1464
+ headers: {
1465
+ 'Content-Type': 'application/json',
1466
+ Authorization: `Bearer ${apiKey}`,
1467
+ 'User-Agent': target.userAgent,
1468
+ },
1469
+ body: JSON.stringify({
1470
+ model: target.model,
1471
+ messages: [{
1472
+ role: 'user',
1473
+ content: [
1474
+ { type: 'video_url', video_url: { url: videoUrl }, fps },
1475
+ { type: 'text', text: prompt },
1476
+ ],
1477
+ }],
1478
+ }),
1479
+ signal: operation.signal,
1480
+ })
1481
+ operation.metrics.upstreamMs += Date.now() - started
1482
+ const body = await response.text().catch(() => '')
1483
+ if (!response.ok) {
1484
+ throw new VisionToolkitError('service', `video request failed with HTTP ${response.status}: ${body.slice(0, 300)}`)
1485
+ }
1486
+ const answer = extractChatAnswer(body)
1487
+ if (answer.trim().length === 0) {
1488
+ throw new VisionToolkitError('output', 'vision API returned an empty answer')
1489
+ }
1490
+ return answer
1491
+ }
1492
+
1493
+ /** Local video basic-info probe: ffprobe metadata, no API or credential. */
1494
+ async videoInfo(request: VideoInfoRequest, options: ToolCallOptions): Promise<VideoInfo> {
1495
+ return this.runOperation('vision_video_info', options, async (operation) => {
1496
+ const policy = await this.pathPolicy(options.workspace)
1497
+ const resolved = await resolveInputVideo(request.video, policy)
1498
+ const metadata = await this.runFfprobe(resolved.path, operation)
1499
+ return { path: resolved.path, bytes: resolved.bytes, ...metadata }
1500
+ })
1501
+ }
1502
+
1503
+ /**
1504
+ * Video understanding: upload the video to object storage and send it plus a
1505
+ * prompt to the first enabled OpenAI provider with video support. Whether the
1506
+ * tool appears in an Agent is governed by the Settings tool-visibility video
1507
+ * bucket, not by provider capability; this method re-checks capability at
1508
+ * call time and returns a "video understanding unavailable" config error when
1509
+ * no enabled provider supports video.
1510
+ */
1511
+ async videoUnderstand(request: VideoUnderstandRequest, options: ToolCallOptions): Promise<VideoUnderstandResult> {
1512
+ return this.runOperation('vision_video_understand', options, async (operation) => {
1513
+ const target = this.videoProvider()
1514
+ if (target === undefined) {
1515
+ throw new VisionToolkitError('config', 'no enabled vision service has video support; enable it in Settings first')
1516
+ }
1517
+ const entry = await this.resolveProviderEnv(target)
1518
+ if (entry === undefined) {
1519
+ throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`)
1520
+ }
1521
+ const prompt = request.prompt.trim()
1522
+ if (prompt.length === 0) {
1523
+ throw new VisionToolkitError('input', 'prompt must not be empty')
1524
+ }
1525
+ const fps = request.fps ?? 2
1526
+ if (!Number.isInteger(fps) || fps < 1 || fps > 120) {
1527
+ throw new VisionToolkitError('input', 'fps must be an integer between 1 and 120')
1528
+ }
1529
+ const policy = await this.pathPolicy(options.workspace)
1530
+ const resolved = await resolveInputVideo(request.video, policy)
1531
+ const client = await this.resolveObjectStorageClient()
1532
+ if (client === undefined) {
1533
+ throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
1534
+ }
1535
+ const uploaded = await client.uploadImage(resolved.path, videoMediaType(extname(resolved.path).toLowerCase()))
1536
+ try {
1537
+ const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, prompt, fps, operation)
1538
+ return { path: resolved.path, answer }
1539
+ } finally {
1540
+ await client.deleteObject(uploaded.key)
1541
+ }
1542
+ })
1543
+ }
1544
+
1256
1545
  /** Stable gate key for one provider's in-flight request cap. */
1257
1546
  private providerGate(provider: ResolvedProvider): Semaphore {
1258
1547
  const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`
@@ -1603,6 +1892,8 @@ export class VisionToolkitRuntime {
1603
1892
  protocol: env.VISION_API_PROTOCOL,
1604
1893
  anthropicThinking: env.VISION_ANTHROPIC_THINKING,
1605
1894
  sslVerify: env.VISION_SSL_VERIFY ?? null,
1895
+ stream: env.VISION_STREAM === '1',
1896
+ uploadViaUrl: provider.uploadViaUrl,
1606
1897
  userAgent: env.VISION_USER_AGENT,
1607
1898
  credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
1608
1899
  maxImageBytes: provider.maxImageBytes,
@@ -1733,24 +2024,31 @@ export class VisionToolkitRuntime {
1733
2024
  return cached.result
1734
2025
  }
1735
2026
  }
1736
- const result = await this.runVisionHedge('glance', [
1737
- ...images.map(image => image.path),
1738
- ...(request.region !== undefined ? ['--region', request.region] : []),
1739
- ...(request.ocr === true ? ['--ocr'] : []),
1740
- ...(request.query !== undefined ? ['-q', request.query] : []),
1741
- ], images, operation, pool)
1742
- const answer = result.stdout.trim()
1743
- if (answer.length === 0) throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
1744
- const value: GlanceResult = {
1745
- images,
1746
- mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
1747
- answer,
1748
- truncated: false,
1749
- }
1750
- if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
1751
- this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value })
2027
+ const transfer = request.region === undefined
2028
+ ? await this.maybeTransferImages(pool, images, operation)
2029
+ : undefined
2030
+ try {
2031
+ const result = await this.runVisionHedge('glance', [
2032
+ ...(transfer !== undefined ? transfer.urls : images.map(image => image.path)),
2033
+ ...(transfer === undefined && request.region !== undefined ? ['--region', request.region] : []),
2034
+ ...(request.ocr === true ? ['--ocr'] : []),
2035
+ ...(request.query !== undefined ? ['-q', request.query] : []),
2036
+ ], images, operation, pool)
2037
+ const answer = result.stdout.trim()
2038
+ if (answer.length === 0) throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
2039
+ const value: GlanceResult = {
2040
+ images,
2041
+ mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
2042
+ answer,
2043
+ truncated: false,
2044
+ }
2045
+ if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
2046
+ this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value })
2047
+ }
2048
+ return value
2049
+ } finally {
2050
+ if (transfer !== undefined) await transfer.cleanup()
1752
2051
  }
1753
- return value
1754
2052
  })
1755
2053
  }
1756
2054
 
@@ -1786,14 +2084,23 @@ export class VisionToolkitRuntime {
1786
2084
  }
1787
2085
  const image = await this.prepareVisionImage(request.image, pool.map(entry => entry.provider), policy, operation)
1788
2086
  this.accountImage(image, operation)
1789
- const result = await this.runVisionHedge(tool, [
1790
- image.path,
1791
- request.target,
1792
- ...(request.region !== undefined ? ['--region', request.region] : []),
1793
- ], [image], operation, pool)
1794
- const elements = parseLocationOutput(result.stdout)
1795
- this.validateLocations(elements, image.width, image.height)
1796
- return { image, elements }
2087
+ const transfer = request.region === undefined
2088
+ ? await this.maybeTransferImages(pool, [image], operation)
2089
+ : undefined
2090
+ try {
2091
+ const result = await this.runVisionHedge(tool, transfer !== undefined
2092
+ ? [transfer.urls[0]!, request.target, '--size', `${image.width}x${image.height}`]
2093
+ : [
2094
+ image.path,
2095
+ request.target,
2096
+ ...(request.region !== undefined ? ['--region', request.region] : []),
2097
+ ], [image], operation, pool)
2098
+ const elements = parseLocationOutput(result.stdout)
2099
+ this.validateLocations(elements, image.width, image.height)
2100
+ return { image, elements }
2101
+ } finally {
2102
+ if (transfer !== undefined) await transfer.cleanup()
2103
+ }
1797
2104
  }
1798
2105
 
1799
2106
  /** ground: locate one named target and return pixel boxes. */
package/src/tools.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type TraceRequest,
23
23
  } from './runtime.ts'
24
24
  import { platformTempDirectory } from './paths.ts'
25
+ import type { VideoInfoRequest, VideoUnderstandRequest } from './video.ts'
25
26
 
26
27
  const renderJson = (_args: unknown, value: unknown): ContentBlock[] => [{
27
28
  type: 'text',
@@ -48,8 +49,55 @@ export const VISION_TOOL_NAMES = {
48
49
  dominantColors: 'vision_dominant_colors',
49
50
  htmlScreenshot: 'vision_html_screenshot',
50
51
  concurrency: 'vision_concurrency',
52
+ videoInfo: 'vision_video_info',
51
53
  } as const
52
54
 
55
+ /**
56
+ * Opt-in video-understanding tool name. Deliberately outside {@link VISION_TOOL_NAMES}
57
+ * so the always-registered canonical set stays unconditional; this tool enters an
58
+ * Agent only when a vision service has video support enabled.
59
+ */
60
+ export const VISION_VIDEO_UNDERSTAND_TOOL = 'vision_video_understand'
61
+
62
+ /**
63
+ * Tool-group buckets used by the `toolVisibility` snapshot. A bucket that is
64
+ * off contributes none of its listed tools to an Agent's visible surface.
65
+ * - local: local-processing tools (no on-line fan-out, no concurrency charge).
66
+ * - online: on-line image tools plus the concurrency/status probe.
67
+ * - video: video-understanding tool (experimental).
68
+ */
69
+ export interface ToolVisibility {
70
+ local: boolean
71
+ online: boolean
72
+ video: boolean
73
+ }
74
+
75
+ const TOOL_BUCKETS: Record<string, 'local' | 'online' | 'video'> = {
76
+ [VISION_TOOL_NAMES.glance]: 'online',
77
+ [VISION_TOOL_NAMES.ground]: 'online',
78
+ [VISION_TOOL_NAMES.detect]: 'online',
79
+ [VISION_TOOL_NAMES.longScreenshotOcr]: 'online',
80
+ [VISION_TOOL_NAMES.concurrency]: 'online',
81
+ [VISION_TOOL_NAMES.trace]: 'local',
82
+ [VISION_TOOL_NAMES.crop]: 'local',
83
+ [VISION_TOOL_NAMES.pixelDiff]: 'local',
84
+ [VISION_TOOL_NAMES.extractForeground]: 'local',
85
+ [VISION_TOOL_NAMES.dominantColors]: 'local',
86
+ [VISION_TOOL_NAMES.htmlScreenshot]: 'local',
87
+ [VISION_TOOL_NAMES.videoInfo]: 'local',
88
+ [VISION_VIDEO_UNDERSTAND_TOOL]: 'video',
89
+ }
90
+
91
+ /** Filter one tool definition by the visibility snapshot's matching bucket. */
92
+ function visibleBySnapshot(definition: { name: string }, snapshot: ToolVisibility): boolean {
93
+ switch (TOOL_BUCKETS[definition.name]) {
94
+ case 'local': return snapshot.local
95
+ case 'online': return snapshot.online
96
+ case 'video': return snapshot.video
97
+ default: return true
98
+ }
99
+ }
100
+
53
101
  /** Resolve the caller workspace exactly like first-party fs/bash tools. */
54
102
  function sessionWorkspace(exec: ToolRunContext): string {
55
103
  return exec.agent?.session.header.cwd ?? process.cwd()
@@ -122,6 +170,19 @@ const requiredBoxSchema = { ...boxSchema, required: true } as const
122
170
  const requiredImageInfoSchema = { ...imageInfoSchema, required: true } as const
123
171
  const requiredArtifactSchema = { ...artifactSchema, required: true } as const
124
172
 
173
+ const nullableStringSchema = {
174
+ oneOf: [{ type: 'string' }, { type: 'null' }],
175
+ } as const satisfies ValueSchemaSpec
176
+ const nullableIntegerSchema = {
177
+ oneOf: [{ type: 'integer' }, { type: 'null' }],
178
+ } as const satisfies ValueSchemaSpec
179
+ const nullableNumberSchema = {
180
+ oneOf: [{ type: 'number' }, { type: 'null' }],
181
+ } as const satisfies ValueSchemaSpec
182
+ const requiredNullableStringSchema = { ...nullableStringSchema, required: true } as const
183
+ const requiredNullableIntegerSchema = { ...nullableIntegerSchema, required: true } as const
184
+ const requiredNullableNumberSchema = { ...nullableNumberSchema, required: true } as const
185
+
125
186
  const locatedMatchSchema = {
126
187
  type: 'object',
127
188
  additionalProperties: false,
@@ -181,17 +242,21 @@ function runtimeFrom(source: VisionToolkitRuntimeSource): VisionToolkitRuntime {
181
242
  * @param source - Current runtime or atomic runtime lookup.
182
243
  * @param projectPresentation - Browser-only projection for Artifact capabilities.
183
244
  * @param lifecycleSignal - Plugin lifetime; aborting it cancels every active tool call.
245
+ * @param toolVisibility - Session-head visibility snapshot; a bucket that is off
246
+ * contributes none of its tools. Defaults to every bucket on.
184
247
  * @returns Native tool definitions registered as one lifecycle generation.
185
248
  */
186
249
  export function createVisionTools(
187
250
  source: VisionToolkitRuntimeSource,
188
251
  projectPresentation: VisionToolkitPresentationProjector = presentationIdentity,
189
252
  lifecycleSignal?: AbortSignal,
253
+ toolVisibility: ToolVisibility = { local: true, online: true, video: false },
190
254
  ): ReturnType<typeof defineTool>[] {
191
255
  const presentationMeta = (_args: unknown, value: JsonValue): JsonValue => projectPresentation(value)
192
- const sessionMaxConcurrency = runtimeFrom(source).sessionMaxConcurrency
256
+ const runtime = runtimeFrom(source)
257
+ const sessionMaxConcurrency = runtime.sessionMaxConcurrency
193
258
  const concurrencyNote = `A single session runs at most ${sessionMaxConcurrency} concurrent vision calls; query vision_concurrency for the live available count. `
194
- return [
259
+ const tools: ReturnType<typeof defineTool>[] = [
195
260
  defineTool({
196
261
  name: VISION_TOOL_NAMES.glance,
197
262
  description: 'Describe, answer a targeted question about, OCR, or compare one or more images with the configured vision model. '
@@ -591,7 +656,8 @@ export function createVisionTools(
591
656
  }),
592
657
  defineTool({
593
658
  name: VISION_TOOL_NAMES.concurrency,
594
- description: 'Report the current available concurrency for vision tool calls in this session: the smaller of the remaining per-session slots and the total remaining model-request slots across enabled providers.',
659
+ description: 'Report the current available concurrency for ON-LINE vision tool calls (glance, ground, detect, long-screenshot OCR, video understanding) in this session: the smaller of the remaining per-session slots and the total remaining model-request slots across enabled providers. '
660
+ + 'Local-processing tools (trace, crop, pixel diff, extract foreground, colors, HTML screenshot, video info) are NOT throttled by this and run immediately.',
595
661
  parameters: {},
596
662
  output: {
597
663
  schema: {
@@ -621,7 +687,72 @@ export function createVisionTools(
621
687
  isConcurrencySafe: () => true,
622
688
  presentCall: () => ({ card: 'generic', title: 'Vision concurrency', kind: 'read', locations: [] }),
623
689
  }),
690
+ defineTool({
691
+ name: VISION_TOOL_NAMES.videoInfo,
692
+ description: 'Probe a local video file and return its basic metadata (container format, duration, resolution, frame rate, codecs, and stream counts) using the bundled ffprobe binary. '
693
+ + 'This is a local operation and never calls a vision API or needs a credential. ' + concurrencyNote + WORKSPACE_NOTE,
694
+ parameters: {
695
+ video: { type: 'string', required: true, description: 'Video file path.' },
696
+ timeoutSeconds: { type: 'integer', description: TIMEOUT_NOTE },
697
+ },
698
+ output: {
699
+ schema: {
700
+ type: 'object', additionalProperties: false, properties: {
701
+ path: { type: 'string', required: true },
702
+ bytes: { type: 'integer', required: true },
703
+ format: { type: 'string', required: true },
704
+ durationSeconds: requiredNullableNumberSchema,
705
+ width: requiredNullableIntegerSchema,
706
+ height: requiredNullableIntegerSchema,
707
+ frameRate: requiredNullableNumberSchema,
708
+ videoCodec: requiredNullableStringSchema,
709
+ audioCodec: requiredNullableStringSchema,
710
+ bitRate: requiredNullableIntegerSchema,
711
+ videoStreamCount: { type: 'integer', required: true },
712
+ audioStreamCount: { type: 'integer', required: true },
713
+ },
714
+ },
715
+ render: renderJson,
716
+ },
717
+ async execute(args: VideoInfoArgs, exec) {
718
+ const request: VideoInfoRequest = { video: args.video }
719
+ return runtimeFrom(source).videoInfo(request, callOptions(exec, args.timeoutSeconds, lifecycleSignal))
720
+ },
721
+ isConcurrencySafe: () => true,
722
+ presentCall: args => ({ card: 'generic', title: `Inspect ${args.video}`, kind: 'read', locations: [{ path: args.video }] }),
723
+ }),
724
+ defineTool({
725
+ name: VISION_VIDEO_UNDERSTAND_TOOL,
726
+ description: 'Send a local video plus a prompt to the configured vision service and return its answer text. '
727
+ + 'The video is uploaded to object storage and passed as a video_url block (Aliyun Qwen format). '
728
+ + `This tool exists only when the video tool bucket is enabled in Settings. Calling it without an enabled vision service that supports video returns a "video understanding unavailable" error. ${UNTRUSTED_EVIDENCE_NOTE} ` + concurrencyNote + WORKSPACE_NOTE,
729
+ parameters: {
730
+ video: { type: 'string', required: true, description: 'Video file path.' },
731
+ prompt: { type: 'string', required: true, description: 'Question or instruction about the video.' },
732
+ fps: { type: 'integer', description: 'Video sampling frame rate passed to the model; default 2.' },
733
+ timeoutSeconds: { type: 'integer', description: TIMEOUT_NOTE },
734
+ },
735
+ output: {
736
+ schema: {
737
+ type: 'object', additionalProperties: false, properties: {
738
+ path: { type: 'string', required: true },
739
+ answer: { type: 'string', required: true },
740
+ },
741
+ },
742
+ render: renderJson,
743
+ },
744
+ async execute(args: VideoUnderstandArgs, exec) {
745
+ const request: VideoUnderstandRequest = {
746
+ video: args.video,
747
+ prompt: args.prompt,
748
+ ...(args.fps === undefined ? {} : { fps: args.fps }),
749
+ }
750
+ return runtimeFrom(source).videoUnderstand(request, callOptions(exec, args.timeoutSeconds, lifecycleSignal))
751
+ },
752
+ presentCall: args => ({ card: 'generic', title: `Understand ${args.video}`, kind: 'read', locations: [{ path: args.video }] }),
753
+ }),
624
754
  ]
755
+ return tools.filter(definition => visibleBySnapshot(definition, toolVisibility))
625
756
  }
626
757
 
627
758
  interface GlanceArgs {
@@ -722,3 +853,13 @@ interface HtmlArgs {
722
853
  output?: string
723
854
  timeoutSeconds?: number
724
855
  }
856
+ interface VideoInfoArgs {
857
+ video: string
858
+ timeoutSeconds?: number
859
+ }
860
+ interface VideoUnderstandArgs {
861
+ video: string
862
+ prompt: string
863
+ fps?: number
864
+ timeoutSeconds?: number
865
+ }
package/src/upstream.ts CHANGED
@@ -41,6 +41,7 @@ export interface UpstreamEnvironment {
41
41
  VISION_API_PROTOCOL: 'chat_completions' | 'anthropic'
42
42
  VISION_ANTHROPIC_THINKING: 'omit' | 'disabled' | 'adaptive'
43
43
  VISION_SSL_VERIFY?: string
44
+ VISION_STREAM?: string
44
45
  VISION_USER_AGENT: string
45
46
  LANG: 'zh' | 'en'
46
47
  }
@@ -761,6 +762,9 @@ export class UpstreamAdapter {
761
762
  ...(options.env.VISION_SSL_VERIFY === undefined
762
763
  ? {}
763
764
  : { VISION_SSL_VERIFY: options.env.VISION_SSL_VERIFY }),
765
+ ...(options.env.VISION_STREAM === undefined
766
+ ? {}
767
+ : { VISION_STREAM: options.env.VISION_STREAM }),
764
768
  VISION_USER_AGENT: options.env.VISION_USER_AGENT,
765
769
  LANG: options.env.LANG,
766
770
  VISION_ENV_FILE: join(prepared.cleanHome, 'vision.env'),