@danceiny/gotry 0.0.1-rc.19 → 0.0.1-rc.21

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 (71) hide show
  1. package/README.md +14 -3
  2. package/README.zh-CN.md +15 -4
  3. package/bin/gotry-bootstrap.js +136 -45
  4. package/bin/gotry-inner.js +11 -6
  5. package/cordis.gotry-patch.yml +19 -8
  6. package/dist/capabilities/anything.js +62 -29
  7. package/dist/capabilities/doctor.js +113 -19
  8. package/dist/capabilities/hbcli.js +28 -2
  9. package/dist/capabilities/session/health-watch.js +10 -3
  10. package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -1
  11. package/dist/scripts/anything-tests.js +70 -21
  12. package/dist/scripts/benchmark-environment-bridge-e2e.js +178 -33
  13. package/dist/scripts/benchmark-environment-bridge-tests.js +1480 -271
  14. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +211 -2
  15. package/dist/scripts/booking-copilot-runtime-proof-tests.js +132 -2
  16. package/dist/scripts/bootstrap-tests.js +30 -3
  17. package/dist/scripts/doctor-tests.js +55 -1
  18. package/dist/scripts/hbcli-e2e-tests.js +1 -1
  19. package/dist/scripts/hbcli-tests.js +38 -1
  20. package/dist/scripts/health-watch-cli.js +9 -1
  21. package/dist/scripts/map-tools-vendor-package-proof.js +276 -0
  22. package/dist/scripts/persona-surface-guard-tests.js +8 -3
  23. package/dist/src/benchmark-agent-conformance.js +187 -17
  24. package/dist/src/benchmark-environment-bridge.js +287 -102
  25. package/dist/src/booking-surface/dsh-planner.js +24 -8
  26. package/dist/src/index.js +5 -2
  27. package/extension/manifest.json +2 -2
  28. package/package.json +3 -1
  29. package/ts/capabilities/anything.ts +89 -38
  30. package/ts/capabilities/hbcli.ts +53 -4
  31. package/ts/capabilities/session/health-watch.ts +9 -2
  32. package/ts/dsh-runtime/vendor/README.md +60 -0
  33. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  34. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
  35. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
  36. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
  37. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  38. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
  39. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  40. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
  41. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  42. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  43. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
  44. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  45. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
  46. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
  47. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  48. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  49. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
  50. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
  51. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  52. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  53. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  54. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  55. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
  56. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  57. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  58. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  59. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  60. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  61. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
  62. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
  63. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  64. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
  65. package/ts/package.json +17 -1
  66. package/ts/scripts/map-tools-vendor-package-proof.ts +268 -0
  67. package/ts/src/benchmark-agent-conformance.ts +175 -13
  68. package/ts/src/benchmark-environment-bridge.ts +220 -66
  69. package/ts/src/booking-surface/dsh-planner.ts +25 -13
  70. package/ts/src/index.ts +4 -2
  71. package/ts/src/turn-deadline.ts +4 -0
@@ -1,17 +1,35 @@
1
1
  import { accessSync, constants, lstatSync, readFileSync, statSync } from 'node:fs'
2
2
  import { getuid } from 'node:process'
3
3
  import { isAbsolute } from 'node:path'
4
- import { defineTool } from '@deepseek-ai/dsh-tools'
4
+ import {
5
+ assertSupportedJsonSchema,
6
+ validateJsonSchemaValue,
7
+ ToolArgsError,
8
+ type ObjectJsonSchema,
9
+ type ToolDefinition,
10
+ } from '@deepseek-ai/dsh-tools'
5
11
  import {
6
12
  validateTerminalOutputConfig,
7
13
  type BenchmarkBridgeProjection,
8
14
  type TerminalOutputConfig,
9
15
  } from './benchmark-agent-conformance.ts'
10
16
 
11
- const SCHEMA_VERSION = 'gotry_benchmark_environment_bridge_v2'
17
+ // v4(#215 Round 12):terminal_output 携带 closed body schema(结构关键字白名单,
18
+ // 禁数据值注解面);v3 及更早配置缺该面,按契约不兼容 fail-closed。
19
+ const SCHEMA_VERSION = 'gotry_benchmark_environment_bridge_v4'
20
+ export const BENCHMARK_TOOL_RESULT_SCHEMA_VERSION = 'gotry_benchmark_tool_result_v1'
21
+ export const BENCHMARK_DOMAIN_RECOVERIES = ['none', 'retry_same', 'revise_arguments', 'choose_alternative'] as const
22
+ type BenchmarkDomainRecovery = typeof BENCHMARK_DOMAIN_RECOVERIES[number]
12
23
  const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]*$/
13
- const CONFIG_KEYS = ['allowed_tools', 'argv_prefix', 'cwd', 'enabled', 'executable', 'isolation', 'max_output_bytes', 'schema_version', 'terminal_output', 'timeout_ms']
14
- const OPTIONAL_CONFIG_KEYS = ['allowed_output_keys']
24
+ const RECOVERIES = new Set<string>(BENCHMARK_DOMAIN_RECOVERIES)
25
+ const CONFIG_KEYS = ['argv_prefix', 'cwd', 'enabled', 'executable', 'isolation', 'max_output_bytes', 'schema_version', 'terminal_output', 'timeout_ms', 'tools']
26
+ const INPUT_SCHEMA_MAX_BYTES = 16 * 1024
27
+ const INPUT_SCHEMA_MAX_DEPTH = 8
28
+ const INPUT_SCHEMA_MAX_NODES = 256
29
+ const INPUT_SCHEMA_MAX_PROPERTIES = 256
30
+ const INPUT_SCHEMA_MAX_ENUM_VALUES = 256
31
+ const INPUT_SCHEMA_MAX_DESCRIPTION_LENGTH = 512
32
+ const INPUT_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'array', 'object'])
15
33
 
16
34
  export interface BenchmarkEnvironmentBridgeConfig {
17
35
  schema_version: typeof SCHEMA_VERSION
@@ -19,8 +37,7 @@ export interface BenchmarkEnvironmentBridgeConfig {
19
37
  executable: string
20
38
  cwd: string
21
39
  argv_prefix: string[]
22
- allowed_tools: string[]
23
- allowed_output_keys?: Record<string, string[]>
40
+ tools: BenchmarkToolDescriptor[]
24
41
  timeout_ms: number
25
42
  max_output_bytes: number
26
43
  terminal_output: TerminalOutputConfig
@@ -31,10 +48,24 @@ export interface BenchmarkEnvironmentBridgeConfig {
31
48
  }
32
49
  }
33
50
 
51
+ export interface BenchmarkToolDescriptor {
52
+ name: string
53
+ description: string
54
+ input_schema: ObjectJsonSchema
55
+ output_keys: string[]
56
+ domain_outcomes: Array<{ status: 'miss' | 'error'; code: string; recovery: BenchmarkDomainRecovery }>
57
+ }
58
+
34
59
  function plainObject(value: unknown): value is Record<string, unknown> {
35
60
  return typeof value === 'object' && value !== null && !Array.isArray(value)
36
61
  }
37
62
 
63
+ function deepFreezeJson<T>(value: T): T {
64
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
65
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreezeJson(child)
66
+ return Object.freeze(value)
67
+ }
68
+
38
69
  function existingExecutable(value: unknown): value is string {
39
70
  if (typeof value !== 'string' || !isAbsolute(value)) return false
40
71
  try {
@@ -55,17 +86,111 @@ function existingDirectory(value: unknown): value is string {
55
86
  }
56
87
  }
57
88
 
58
- function identifiers(value: unknown): value is string[] {
59
- return Array.isArray(value) && value.length > 0 && value.length <= 64 && new Set(value).size === value.length && value.every(
89
+ function identifiers(value: unknown, allowEmpty = false): value is string[] {
90
+ return Array.isArray(value) && (allowEmpty || value.length > 0) && value.length <= 64 && new Set(value).size === value.length && value.every(
60
91
  item => typeof item === 'string' && item.length > 0 && IDENTIFIER.test(item),
61
92
  )
62
93
  }
63
94
 
64
- function allowedOutputKeys(value: unknown, allowedTools: string[]): value is Record<string, string[]> {
95
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
96
+ return Object.keys(value).every(key => allowed.includes(key))
97
+ }
98
+
99
+ function validDescription(value: unknown, required = false): boolean {
100
+ if (value === undefined) return !required
101
+ return typeof value === 'string'
102
+ && (!required || value.trim().length > 0)
103
+ && value.length <= INPUT_SCHEMA_MAX_DESCRIPTION_LENGTH
104
+ && !/[\u0000-\u001f\u007f]/.test(value)
105
+ }
106
+
107
+ function scalarMatches(type: string, value: unknown): boolean {
108
+ if (type === 'string') return typeof value === 'string' && value.length <= 512
109
+ if (type === 'number') return typeof value === 'number' && Number.isFinite(value)
110
+ if (type === 'integer') return typeof value === 'number' && Number.isSafeInteger(value)
111
+ if (type === 'boolean') return typeof value === 'boolean'
112
+ return type === 'null' && value === null
113
+ }
114
+
115
+ function validInputSchema(value: unknown): value is ObjectJsonSchema {
65
116
  if (!plainObject(value)) return false
66
- const keys = Object.keys(value)
67
- return keys.length > 0 && keys.length <= 64
68
- && keys.every(tool => allowedTools.includes(tool) && identifiers(value[tool]))
117
+ let serialized: string
118
+ try {
119
+ serialized = JSON.stringify(value)
120
+ } catch {
121
+ return false
122
+ }
123
+ if (Buffer.byteLength(serialized, 'utf8') > INPUT_SCHEMA_MAX_BYTES) return false
124
+
125
+ const pending: Array<{ node: Record<string, unknown>; depth: number }> = [{ node: value, depth: 0 }]
126
+ const seen = new Set<object>()
127
+ let nodes = 0
128
+ let properties = 0
129
+ let enumValues = 0
130
+ while (pending.length > 0) {
131
+ const { node, depth } = pending.pop()!
132
+ if (seen.has(node) || ++nodes > INPUT_SCHEMA_MAX_NODES || depth > INPUT_SCHEMA_MAX_DEPTH) return false
133
+ seen.add(node)
134
+ if (!INPUT_SCHEMA_TYPES.has(String(node.type)) || !validDescription(node.description)) return false
135
+
136
+ if (node.type === 'object') {
137
+ if (!exactKeys(node, ['type', 'description', 'properties', 'required', 'additionalProperties'])
138
+ || !plainObject(node.properties)
139
+ || !Array.isArray(node.required)
140
+ || node.additionalProperties !== false) return false
141
+ const propertyEntries = Object.entries(node.properties)
142
+ properties += propertyEntries.length
143
+ if (properties > INPUT_SCHEMA_MAX_PROPERTIES || propertyEntries.some(([key, child]) => !IDENTIFIER.test(key) || !plainObject(child))) return false
144
+ const required = node.required
145
+ if (required.length > propertyEntries.length
146
+ || new Set(required).size !== required.length
147
+ || required.some(key => typeof key !== 'string' || !Object.hasOwn(node.properties as Record<string, unknown>, key))) return false
148
+ for (const [, child] of propertyEntries) pending.push({ node: child as Record<string, unknown>, depth: depth + 1 })
149
+ continue
150
+ }
151
+
152
+ if (node.type === 'array') {
153
+ if (!exactKeys(node, ['type', 'description', 'items']) || !plainObject(node.items)) return false
154
+ pending.push({ node: node.items, depth: depth + 1 })
155
+ continue
156
+ }
157
+
158
+ if (!exactKeys(node, ['type', 'description', 'enum', 'const'])
159
+ || (Object.hasOwn(node, 'enum') && Object.hasOwn(node, 'const'))) return false
160
+ if (Object.hasOwn(node, 'const') && !scalarMatches(String(node.type), node.const)) return false
161
+ if (Object.hasOwn(node, 'enum')) {
162
+ if (!Array.isArray(node.enum) || node.enum.length === 0 || node.enum.length > 64) return false
163
+ enumValues += node.enum.length
164
+ if (enumValues > INPUT_SCHEMA_MAX_ENUM_VALUES
165
+ || new Set(node.enum).size !== node.enum.length
166
+ || node.enum.some(item => !scalarMatches(String(node.type), item))) return false
167
+ }
168
+ }
169
+ return value.type === 'object'
170
+ }
171
+
172
+ function toolDescriptors(value: unknown): value is BenchmarkToolDescriptor[] {
173
+ if (!Array.isArray(value) || value.length === 0 || value.length > 64) return false
174
+ const names = new Set<string>()
175
+ return value.every(item => {
176
+ if (!plainObject(item)) return false
177
+ const keys = Object.keys(item).sort()
178
+ const required = ['description', 'domain_outcomes', 'input_schema', 'name']
179
+ if (JSON.stringify(keys) !== JSON.stringify([...required, 'output_keys'].sort())) return false
180
+ if (typeof item.name !== 'string' || !IDENTIFIER.test(item.name) || names.has(item.name)) return false
181
+ names.add(item.name)
182
+ if (!validDescription(item.description, true) || !validInputSchema(item.input_schema)) return false
183
+ if (!Array.isArray(item.domain_outcomes) || item.domain_outcomes.length > 64) return false
184
+ const outcomeKeys = new Set<string>()
185
+ if (!item.domain_outcomes.every(outcome => plainObject(outcome)
186
+ && JSON.stringify(Object.keys(outcome).sort()) === JSON.stringify(['code', 'recovery', 'status'])
187
+ && (outcome.status === 'miss' || outcome.status === 'error')
188
+ && typeof outcome.code === 'string' && IDENTIFIER.test(outcome.code)
189
+ && typeof outcome.recovery === 'string' && RECOVERIES.has(outcome.recovery)
190
+ && !outcomeKeys.has(outcome.code)
191
+ && (outcomeKeys.add(outcome.code), true))) return false
192
+ return identifiers(item.output_keys)
193
+ })
69
194
  }
70
195
 
71
196
  // These are passed as direct argv entries (never through a shell), so options
@@ -98,20 +223,19 @@ function validBound(value: unknown, maximum: number): value is number {
98
223
  function validate(value: unknown): value is BenchmarkEnvironmentBridgeConfig {
99
224
  const configKeys = plainObject(value) ? Object.keys(value).sort() : []
100
225
  const baseKeys = [...CONFIG_KEYS].sort()
101
- const extendedKeys = [...CONFIG_KEYS, ...OPTIONAL_CONFIG_KEYS].sort()
102
226
  if (!plainObject(value)
103
- || ![baseKeys, extendedKeys].some(keys => JSON.stringify(configKeys) === JSON.stringify(keys))
227
+ || JSON.stringify(configKeys) !== JSON.stringify(baseKeys)
104
228
  || value.schema_version !== SCHEMA_VERSION
105
229
  || value.enabled !== true
106
230
  || !existingExecutable(value.executable)
107
231
  || !existingDirectory(value.cwd)
108
232
  || !argvPrefix(value.argv_prefix)
109
- || !identifiers(value.allowed_tools)
233
+ || !toolDescriptors(value.tools)
110
234
  || !validBound(value.timeout_ms, 120_000)
111
235
  || !validBound(value.max_output_bytes, 10 * 1024 * 1024)
112
236
  || !validateTerminalOutputConfig(value.terminal_output)
113
237
  || !exactIsolation(value.isolation)
114
- || (Object.hasOwn(value, 'allowed_output_keys') && !allowedOutputKeys(value.allowed_output_keys, value.allowed_tools))) return false
238
+ ) return false
115
239
  return true
116
240
  }
117
241
 
@@ -124,13 +248,13 @@ export function loadBenchmarkEnvironmentConfig(path: string): BenchmarkEnvironme
124
248
  if (typeof getuid !== 'function' || metadata.uid !== getuid()) return null
125
249
  if ((metadata.mode & 0o022) !== 0) return null
126
250
  const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
127
- return validate(parsed) ? parsed : null
251
+ return validate(parsed) ? deepFreezeJson(parsed) : null
128
252
  } catch {
129
253
  return null
130
254
  }
131
255
  }
132
256
 
133
- type Register = (tool: ReturnType<typeof defineTool>) => void
257
+ type Register = (tool: ToolDefinition) => void
134
258
  interface CollectedText {
135
259
  readFrom(offset: number): { text: string; nextOffset: number; lossy?: boolean }
136
260
  }
@@ -176,6 +300,19 @@ const FORBIDDEN_OUTPUT_KEY_FRAGMENTS = [
176
300
  'loadermetadata', 'oracle', 'reference', 'reward', 'score',
177
301
  ]
178
302
 
303
+ export const BRIDGE_ERROR_CONTRACT: Record<string, { recoverable: boolean; remedy: string }> = Object.freeze({
304
+ invalid_action: { recoverable: true, remedy: 'action 只允许 tools、call 或 errors' },
305
+ disallowed_tool: { recoverable: true, remedy: '先 action=tools 列出 allowed_tools 清单,再从清单内选工具' },
306
+ invalid_arguments: { recoverable: true, remedy: 'arguments 必须是可序列化 JSON 对象(≤64KB、深度≤12);收窄后重试' },
307
+ timed_out: { recoverable: true, remedy: '上游超时;可原样重试一次或换其他工具' },
308
+ output_truncated: { recoverable: true, remedy: '输出超出上限;让被调工具收窄查询范围后重试' },
309
+ invalid_json: { recoverable: true, remedy: '上游输出不是合法 JSON;重试一次或换工具' },
310
+ invalid_output: { recoverable: true, remedy: '上游输出结构不符;重试或换工具' },
311
+ runner_failed: { recoverable: true, remedy: '上游进程非零退出;可重试一次,持续失败换工具' },
312
+ spawn_failed: { recoverable: false, remedy: '可执行文件不可用——环境问题,调用方不可恢复' },
313
+ forbidden_output: { recoverable: false, remedy: '输出含未授权键(策略边界)——不可恢复,换工具或放弃' },
314
+ })
315
+
179
316
  function inspectOutput(value: unknown): 'ok' | 'forbidden_key' | 'structure_limit' {
180
317
  const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]
181
318
  let nodes = 0
@@ -199,19 +336,22 @@ function inspectOutput(value: unknown): 'ok' | 'forbidden_key' | 'structure_limi
199
336
 
200
337
  function inspectAllowedOutput(value: unknown, allowedKeys: string[]): boolean {
201
338
  const allowed = new Set(allowedKeys)
202
- const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]
339
+ const pending: Array<{ value: unknown; depth: number; coveredByDeclaredKey: boolean }> = [{ value, depth: 0, coveredByDeclaredKey: false }]
203
340
  let nodes = 0
204
341
  while (pending.length > 0) {
205
342
  const current = pending.pop()!
206
343
  if (++nodes > 10_000 || current.depth > 24) return false
207
344
  if (Array.isArray(current.value)) {
208
- for (const child of current.value) pending.push({ value: child, depth: current.depth + 1 })
345
+ for (const child of current.value) pending.push({ value: child, depth: current.depth + 1, coveredByDeclaredKey: current.coveredByDeclaredKey })
346
+ continue
347
+ }
348
+ if (!plainObject(current.value)) {
349
+ if (!current.coveredByDeclaredKey) return false
209
350
  continue
210
351
  }
211
- if (!plainObject(current.value)) continue
212
352
  for (const [key, child] of Object.entries(current.value)) {
213
353
  if (!allowed.has(key)) return false
214
- pending.push({ value: child, depth: current.depth + 1 })
354
+ pending.push({ value: child, depth: current.depth + 1, coveredByDeclaredKey: true })
215
355
  }
216
356
  }
217
357
  return true
@@ -244,21 +384,6 @@ function serializedArguments(value: Record<string, unknown>): { json: string; re
244
384
  }
245
385
  }
246
386
 
247
- /** 可恢复 domain-error 契约(Round 8,issue #100/#102):封闭词表 + 逐码可恢复性与补救指引。
248
- * 模型经 action=errors 拉取全表;失败返回的扁平形状保持不变(benchmark 诊断面依赖)。 */
249
- export const BRIDGE_ERROR_CONTRACT: Record<string, { recoverable: boolean; remedy: string }> = Object.freeze({
250
- invalid_action: { recoverable: true, remedy: 'action 只允许 tools 或 call' },
251
- disallowed_tool: { recoverable: true, remedy: '先 action=tools 列出 allowed_tools 清单,再从清单内选工具' },
252
- invalid_arguments: { recoverable: true, remedy: 'arguments 必须是可序列化 JSON 对象(≤64KB、深度≤12);收窄后重试' },
253
- timed_out: { recoverable: true, remedy: '上游超时;可原样重试一次或换其他工具' },
254
- output_truncated: { recoverable: true, remedy: '输出超出上限;让被调工具收窄查询范围后重试' },
255
- invalid_json: { recoverable: true, remedy: '上游输出不是合法 JSON;重试一次或换工具' },
256
- invalid_output: { recoverable: true, remedy: '上游输出结构不符(如原始字符串);重试或换工具' },
257
- runner_failed: { recoverable: true, remedy: '上游进程非零退出;可重试一次,持续失败换工具' },
258
- spawn_failed: { recoverable: false, remedy: '可执行文件不可用——环境问题,调用方不可恢复' },
259
- forbidden_output: { recoverable: false, remedy: '输出含未授权键(策略边界)——不可恢复,换工具或放弃' },
260
- })
261
-
262
387
  /** Register the opt-in model-facing bridge. */
263
388
  export function registerBenchmarkEnvironmentBridge(
264
389
  path: string,
@@ -269,35 +394,57 @@ export function registerBenchmarkEnvironmentBridge(
269
394
  if (!bridge) throw new Error('benchmark environment bridge configuration unavailable')
270
395
  if (!subprocess) throw new Error('benchmark environment bridge subprocess unavailable')
271
396
 
272
- register(defineTool({
273
- name: 'gotry_benchmark_environment',
274
- description: 'When a prompt asks to run agent_env.cli, use action=call here with the mapped tool; arbitrary shell is not exposed. '
275
- + 'Recoverable domain-error contract: every failure returns { ok:false, error:<code>, ... } from a closed vocabulary; '
276
- + 'call action=errors to fetch the per-code recoverable flag and remedy before retrying.',
277
- // Round 8(issue #100/#102):generic typed schema——模型可见逐字段契约(与产品面 D-30 同刀法);
278
- // arguments 保持 additionalProperties:true(被调工具的参数面由其自身契约定义)
279
- parameters: {
280
- action: { type: 'string', enum: ['tools', 'call', 'errors'], required: true, description: 'tools=列出可调工具;call=执行被映射工具;errors=拉取可恢复错误契约表' },
281
- tool: { type: 'string', description: '被调工具名(必须在 allowed_tools 清单内;action=call 时必填)' },
282
- arguments: { type: 'object', additionalProperties: true, description: '传给被调工具的参数对象(action=call 时可选;≤64KB、深度≤12)' },
397
+ const parameters: Record<string, unknown> = deepFreezeJson({
398
+ type: 'object',
399
+ description: 'Flat benchmark bridge wire: choose an action, then provide the mapped tool and its arguments.',
400
+ properties: {
401
+ action: { type: 'string', enum: ['tools', 'call', 'errors'], description: 'tools=列出可调工具;call=执行工具;errors=拉取错误契约' },
402
+ tool: { type: 'string', enum: bridge.tools.map(item => item.name), description: 'action=call 时选择的冻结工具名' },
403
+ arguments: { type: 'object', additionalProperties: true, description: 'action=call 时传给工具的参数对象' },
283
404
  },
405
+ required: ['action'],
406
+ additionalProperties: false,
407
+ })
408
+ assertSupportedJsonSchema(parameters)
409
+
410
+ const definition: ToolDefinition = {
411
+ name: 'gotry_benchmark_environment',
412
+ description: 'When a prompt asks to run agent_env.cli, use action=call here with the mapped tool; arbitrary shell is not exposed.',
413
+ parameters,
284
414
  output: {
285
- schema: { type: 'json' },
415
+ schema: {},
286
416
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
287
417
  },
288
- async execute(args): Promise<Record<string, never>> {
289
- const action = args?.action
290
- if (action === 'errors') return jsonObject({ ok: true, errors: BRIDGE_ERROR_CONTRACT })
291
- if (action === 'tools') return jsonObject({ ok: true, tools: bridge.allowed_tools })
292
- if (action !== 'call') return jsonObject({ ok: false, error: 'invalid_action' })
293
- const query = args as unknown as { tool?: unknown; arguments?: unknown }
294
- if (typeof query.tool !== 'string' || !bridge.allowed_tools.includes(query.tool)) {
418
+ async execute(args: unknown): Promise<Record<string, never>> {
419
+ const violations = validateJsonSchemaValue(parameters, args, 'arguments')
420
+ if (violations.length > 0) throw new ToolArgsError(violations)
421
+ const query = args as Record<string, unknown>
422
+ const tool = query.tool
423
+ const descriptor = bridge.tools.find(item => item.name === tool)
424
+ const keys = Object.keys(query)
425
+ if (query.action === 'tools') {
426
+ if (keys.length !== 1) throw new ToolArgsError(['arguments: tools action accepts no tool or arguments'])
427
+ return jsonObject({ ok: true, tools: bridge.tools })
428
+ }
429
+ if (query.action === 'errors') {
430
+ if (keys.length !== 1) throw new ToolArgsError(['arguments: errors action accepts no tool or arguments'])
431
+ return jsonObject({ ok: true, errors: BRIDGE_ERROR_CONTRACT })
432
+ }
433
+ if (query.action !== 'call') {
434
+ return jsonObject({ ok: false, error: 'invalid_action' })
435
+ }
436
+ if (keys.length !== 3 || !Object.hasOwn(query, 'tool') || !Object.hasOwn(query, 'arguments')) {
437
+ throw new ToolArgsError(['arguments: call action requires exactly tool and arguments'])
438
+ }
439
+ if (typeof tool !== 'string' || !descriptor) {
295
440
  return jsonObject({ ok: false, error: 'disallowed_tool' })
296
441
  }
297
- if (query.arguments !== undefined && !plainObject(query.arguments)) {
442
+ if (!plainObject(query.arguments)) {
298
443
  return jsonObject({ ok: false, error: 'invalid_arguments' })
299
444
  }
300
- const callArguments = query.arguments === undefined ? {} : query.arguments
445
+ const callArguments = query.arguments
446
+ const argumentViolations = validateJsonSchemaValue(descriptor.input_schema, callArguments, 'arguments')
447
+ if (argumentViolations.length > 0) throw new ToolArgsError(argumentViolations)
301
448
  const serialized = serializedArguments(callArguments)
302
449
  if (serialized.reason) return jsonObject({ ok: false, error: 'invalid_arguments', reason: serialized.reason })
303
450
  const controller = new AbortController()
@@ -315,7 +462,7 @@ export function registerBenchmarkEnvironmentBridge(
315
462
  env.PYTHONDONTWRITEBYTECODE = '1'
316
463
  env.PYTHONNOUSERSITE = '1'
317
464
  handle = subprocess.spawn({
318
- argv: [bridge.executable, ...bridge.argv_prefix, 'call', query.tool, serialized.json!],
465
+ argv: [bridge.executable, ...bridge.argv_prefix, 'call', tool, serialized.json!],
319
466
  cwd: bridge.cwd,
320
467
  stdio: {
321
468
  stdin: 'ignore',
@@ -344,12 +491,18 @@ export function registerBenchmarkEnvironmentBridge(
344
491
  const outputInspection = inspectOutput(parsed)
345
492
  if (outputInspection === 'forbidden_key') return jsonObject({ ok: false, error: 'forbidden_output' })
346
493
  if (outputInspection === 'structure_limit') return jsonObject({ ok: false, error: 'invalid_output' })
347
- const visibleResult = parsed.result ?? parsed
494
+ if (parsed.schema_version !== BENCHMARK_TOOL_RESULT_SCHEMA_VERSION || typeof parsed.status !== 'string') {
495
+ return jsonObject({ ok: false, error: 'invalid_output' })
496
+ }
497
+ if (parsed.status === 'miss' || parsed.status === 'error') {
498
+ if (typeof parsed.code !== 'string' || typeof parsed.recovery !== 'string' || !descriptor.domain_outcomes.some(outcome => outcome.status === parsed.status && outcome.code === parsed.code && outcome.recovery === parsed.recovery)) return jsonObject({ ok: false, error: 'invalid_output' })
499
+ if (Object.keys(parsed).sort().join(',') !== 'code,recovery,schema_version,status') return jsonObject({ ok: false, error: 'invalid_output' })
500
+ return jsonObject({ ok: true, outcome: parsed })
501
+ }
502
+ if (parsed.status !== 'ok' || !Object.hasOwn(parsed, 'result') || Object.keys(parsed).sort().join(',') !== 'result,schema_version,status') return jsonObject({ ok: false, error: 'invalid_output' })
503
+ const visibleResult = parsed.result
348
504
  if (!plainObject(visibleResult) && !Array.isArray(visibleResult)) return jsonObject({ ok: false, error: 'invalid_output' })
349
- const outputKeys = bridge.allowed_output_keys && Object.hasOwn(bridge.allowed_output_keys, query.tool)
350
- ? bridge.allowed_output_keys[query.tool]
351
- : undefined
352
- if (outputKeys && !inspectAllowedOutput(visibleResult, outputKeys)) return jsonObject({ ok: false, error: 'forbidden_output' })
505
+ if (!inspectAllowedOutput(visibleResult, descriptor.output_keys)) return jsonObject({ ok: false, error: 'forbidden_output' })
353
506
  return jsonObject({ ok: true, result: visibleResult })
354
507
  } catch {
355
508
  if (controller.signal.aborted) return jsonObject({ ok: false, error: 'timed_out' })
@@ -358,10 +511,11 @@ export function registerBenchmarkEnvironmentBridge(
358
511
  clearTimeout(timer)
359
512
  }
360
513
  },
361
- }))
514
+ }
515
+ register(definition)
362
516
  return Object.freeze({
363
517
  toolName: 'gotry_benchmark_environment',
364
- allowedTools: Object.freeze([...bridge.allowed_tools]),
518
+ allowedTools: Object.freeze(bridge.tools.map(item => item.name)),
365
519
  terminal: Object.freeze({ ...bridge.terminal_output }),
366
520
  })
367
521
  }
@@ -407,9 +407,11 @@ function recoverFinalResponseDecision(response: string, task: BookingCopilotTask
407
407
  console.error('[booking-copilot] finalResponse recovery rejected (invalid action):', JSON.stringify({ kind: action.kind, errors: validation.errors.slice(0, 6) }).slice(0, 600))
408
408
  return null
409
409
  }
410
- repairPlannerFactRefs(action)
410
+ const repairedRefs = repairPlannerFactRefs(action)
411
+ const repairedValidation = validateBookingReadAction(action as unknown as BookingReadAction)
412
+ if (!repairedValidation.ok) return null
411
413
  try {
412
- assertPlannerSafeRefs(action)
414
+ assertPlannerSafeRefs(action, repairedRefs)
413
415
  } catch (error) {
414
416
  console.error('[booking-copilot] finalResponse recovery rejected (unsafe ref):', JSON.stringify({ actionId: action.actionId, factRefs: action.factRefs }).slice(0, 600))
415
417
  return null
@@ -426,17 +428,22 @@ function recoverFinalResponseDecision(response: string, task: BookingCopilotTask
426
428
  const PLANNER_SAFE_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._-]*$/
427
429
 
428
430
  // Models cite prompt facts in URI-ish syntax (`fact://turn_X/request`,
429
- // `turn_X#request`); characters outside the runtime ref charset are mapped
430
- // deterministically to '.' so repair succeeds without burning the retry
431
- // budget. Anything the sanitizer cannot make unique enough still fails the
432
- // safe-ref gate into the retry path.
433
- function repairPlannerFactRefs(action: Record<string, unknown>): void {
431
+ // `turn_X#request`). Preserve already-safe refs exactly; unsafe refs enter the
432
+ // reserved modelref namespace with the full SHA-256 of the raw UTF-8 value.
433
+ function repairPlannerFactRefs(action: Record<string, unknown>): Set<string> {
434
434
  const factRefs = action.factRefs
435
- if (!Array.isArray(factRefs)) return
436
- action.factRefs = factRefs.map((ref) => (typeof ref === 'string' ? ref.replace(/#/g, ':').replace(/[^A-Za-z0-9:._-]/g, '.') : ref))
435
+ const repairedRefs = new Set<string>()
436
+ if (!Array.isArray(factRefs)) return repairedRefs
437
+ action.factRefs = factRefs.map((ref) => {
438
+ if (typeof ref !== 'string' || PLANNER_SAFE_REF_PATTERN.test(ref)) return ref
439
+ const alias = `modelref:${createHash('sha256').update(ref, 'utf8').digest('hex')}`
440
+ repairedRefs.add(alias)
441
+ return alias
442
+ })
443
+ return repairedRefs
437
444
  }
438
445
 
439
- function assertPlannerSafeRefs(action: Record<string, unknown>): void {
446
+ function assertPlannerSafeRefs(action: Record<string, unknown>, repairedRefs: Set<string>): void {
440
447
  const actionId = action.actionId
441
448
  if (typeof actionId !== 'string' || !PLANNER_SAFE_REF_PATTERN.test(actionId)) {
442
449
  throw new Error(`planner_invalid_action:unsafe_action_id:${String(actionId).slice(0, 60)}`)
@@ -444,7 +451,7 @@ function assertPlannerSafeRefs(action: Record<string, unknown>): void {
444
451
  const factRefs = action.factRefs
445
452
  if (Array.isArray(factRefs)) {
446
453
  for (const ref of factRefs) {
447
- if (typeof ref !== 'string' || !PLANNER_SAFE_REF_PATTERN.test(ref)) {
454
+ if (typeof ref !== 'string' || !PLANNER_SAFE_REF_PATTERN.test(ref) || (ref.startsWith('modelref:') && !repairedRefs.has(ref)) || ref.length > 512) {
448
455
  throw new Error(`planner_invalid_action:unsafe_fact_ref:${String(ref).slice(0, 60)}`)
449
456
  }
450
457
  }
@@ -502,8 +509,13 @@ function parseToolDecision(event: unknown, task: BookingCopilotTaskState): Booki
502
509
  // boundary, past the retry budget. Repair the common fragment syntax first,
503
510
  // then enforce the same charset here so remaining violations retry as
504
511
  // parse-class failures instead of failing the turn as PLANNER_FAILED.
505
- repairPlannerFactRefs(decision.action)
506
- assertPlannerSafeRefs(decision.action)
512
+ const repairedRefs = repairPlannerFactRefs(decision.action)
513
+ const repairedValidation = validateBookingReadAction(decision.action)
514
+ if (!repairedValidation.ok) {
515
+ console.error(`[booking-copilot] repaired action rejected:`, JSON.stringify({ errors: repairedValidation.errors.slice(0, 8) }).slice(0, 800))
516
+ throw new Error(`planner_invalid_action:${repairedValidation.errors.join('; ')}`)
517
+ }
518
+ assertPlannerSafeRefs(decision.action, repairedRefs)
507
519
  const action = decision.action as unknown as BookingReadAction
508
520
  const capability = TOOL_TO_CAPABILITY.get(name as DshEmbeddedBookingToolName)
509
521
  if (!capability || !actionsForEmbeddedCapability(capability).includes(action.kind)) {
package/ts/src/index.ts CHANGED
@@ -1210,14 +1210,16 @@ export function apply(ctx: Context, config: Config): void {
1210
1210
  if (!q.keyword) {
1211
1211
  return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
1212
1212
  }
1213
- const itpA = await interpretEffect({ effect: 'ANYTHING_SEARCH', params: q })
1213
+ // hbcliBin 直通 config(缺省 'hbcli',能力层按已知安装位回退)——issue #195 教训:
1214
+ // 此前 params 只透 q,config.hbcliBin 永远到不了能力层
1215
+ const itpA = await interpretEffect({ effect: 'ANYTHING_SEARCH', params: { ...q, hbcliBin: config.hbcliBin } })
1214
1216
  if (!itpA.result) return declinedObservation('ANYTHING_SEARCH', itpA.trace)
1215
1217
  const r = itpA.result
1216
1218
  const dir = await ensureStateDir(config.stateRoot)
1217
1219
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `anything:${r.via}`).catch(() => {})
1218
1220
  const top5 = (r.hits ?? []).slice(0, 5)
1219
1221
  const summary = r.verdict === 'hit'
1220
- ? `${q.keyword} → hit (${r.hits?.length ?? 0} 候选项)\n${top5.map((h, i) => ` ${i + 1}. [${h.type}] ${h.name}${h.latitude !== undefined && h.longitude !== undefined ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})` : ''}`).join('\n')}\n${r.evidence}`
1222
+ ? `${q.keyword} → hit (${r.hits?.length ?? 0} 候选项)\n${top5.map((h, i) => ` ${i + 1}. [${h.type}] ${h.name}${h.star ? ` ${h.star}★` : ''}${h.latitude !== undefined && h.longitude !== undefined ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})` : ''}${h.hotelId ? ` hotelId=${h.hotelId}` : h.destinationId ? ` destinationId=${h.destinationId}` : ''}`).join('\n')}\n${r.evidence}`
1221
1223
  : r.verdict === 'miss'
1222
1224
  ? `${q.keyword} → miss (酒店-be 一切正常但无候选)\n${r.evidence}`
1223
1225
  : `${q.keyword} → unavailable (${r.error})\n${r.evidence}`
@@ -22,6 +22,10 @@ import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
22
22
  import { join } from 'node:path'
23
23
  import type { Context } from '@deepseek-ai/cordis'
24
24
  import type { Agent } from '@deepseek-ai/dsh-agent'
25
+ // 类型面显式依赖:session/event、session/disposed 的事件表声明在 dsh-session 的
26
+ // cordis Events augmentation 里——不显式导入时,该声明只在 peer 恰好物化进
27
+ // ts/node_modules 的机器上可见(legacy-peer-deps 安装则缺,5 个 TS2345/TS7006)。
28
+ import type {} from '@deepseek-ai/dsh-session'
25
29
  import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
26
30
  import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor, type TurnPolicy } from './turn-policy.ts'
27
31