@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
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Package proof for issue #202.
3
+ *
4
+ * The full suite passes the executable from its clean tarball install; focused
5
+ * runs pack/unpack locally and attach the already-installed root closure. Both
6
+ * paths load the plugin from the artifact, never from the checkout vendor path.
7
+ * No map provider or paid endpoint is contacted; inline-coordinate geocoding is
8
+ * asserted with fetch disabled.
9
+ */
10
+
11
+ import { execFileSync } from 'node:child_process'
12
+ import { createHash } from 'node:crypto'
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
14
+ import { createRequire } from 'node:module'
15
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
+ import assert from 'node:assert/strict'
18
+
19
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
20
+ const upstreamLicenseSha256 = 'b6bbb0c73a02cf8d2c304e9f208b41c32f0a810fabe366986e2b14ac3338f618'
21
+ const upstreamVendorFileCount = 32
22
+ const upstreamVendorAggregateSha256 = 'd82a38adace8bfe574dcecb4222da5c994a1b9e8792f78dc2dad3a7f50c9925a'
23
+ const expectedTools = [
24
+ 'map_bicycling_route',
25
+ 'map_driving_route',
26
+ 'map_geocode',
27
+ 'map_poi_search',
28
+ 'map_reverse_geocode',
29
+ 'map_transit_route',
30
+ 'map_walking_route',
31
+ ]
32
+
33
+ function sha256(path: string): string {
34
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
35
+ }
36
+
37
+ function vendorAggregate(root: string): { count: number; sha256: string } {
38
+ const files: string[] = []
39
+ const walk = (directory: string) => {
40
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
41
+ const path = join(directory, entry.name)
42
+ if (entry.isDirectory()) walk(path)
43
+ else files.push(path.slice(root.length + 1).replaceAll('\\', '/'))
44
+ }
45
+ }
46
+ walk(root)
47
+ files.sort()
48
+ const digest = createHash('sha256')
49
+ for (const relative of files) {
50
+ digest.update(relative)
51
+ digest.update('\0')
52
+ digest.update(readFileSync(join(root, relative)))
53
+ digest.update('\0')
54
+ }
55
+ return { count: files.length, sha256: digest.digest('hex') }
56
+ }
57
+
58
+ function assertAlpha3LockClosure(): { packages: number; version: string } {
59
+ const manifest = JSON.parse(readFileSync(join(repoRoot, 'ts/package.json'), 'utf8')) as {
60
+ dependencies?: Record<string, string>
61
+ }
62
+ assert.equal(manifest.dependencies?.['dsh-map-tools'], undefined, 'external map-tools dependency must stay removed')
63
+
64
+ const lock = JSON.parse(readFileSync(join(repoRoot, 'ts/package-lock.json'), 'utf8')) as {
65
+ packages?: Record<string, { version?: string; dependencies?: Record<string, string> }>
66
+ }
67
+ assert.equal(lock.packages?.['']?.dependencies?.['dsh-map-tools'], undefined, 'lock root must not resolve external map-tools')
68
+ assert.equal(lock.packages?.['node_modules/dsh-map-tools'], undefined, 'lock must not carry external map-tools')
69
+ const dshEntries = Object.entries(lock.packages ?? {}).filter(([path]) =>
70
+ /(?:^|\/)node_modules\/@deepseek-ai\/(?:dsh|dsh-[^/]+)$/.test(path),
71
+ )
72
+ assert.ok(dshEntries.length >= 10, 'expected dsh-tools peer closure in the ts lock')
73
+ for (const [path, entry] of dshEntries) {
74
+ assert.equal(entry.version, '0.1.2-alpha.3', `${path} drifted outside the alpha.3 closure`)
75
+ }
76
+ return { packages: dshEntries.length, version: '0.1.2-alpha.3' }
77
+ }
78
+
79
+ function findPackageRoot(entry: string): string {
80
+ let cursor = dirname(entry)
81
+ for (;;) {
82
+ const manifestPath = join(cursor, 'package.json')
83
+ if (existsSync(manifestPath)) {
84
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name?: string }
85
+ if (manifest.name === '@danceiny/gotry') return cursor
86
+ }
87
+ const parent = dirname(cursor)
88
+ if (parent === cursor) throw new Error(`cannot locate @danceiny/gotry package root from ${entry}`)
89
+ cursor = parent
90
+ }
91
+ }
92
+
93
+ function assertBinTargetsPackage(binPath: string, expectedTarget: string): void {
94
+ const resolvedExpectedTarget = realpathSync(expectedTarget)
95
+ if (realpathSync(binPath) === resolvedExpectedTarget) return
96
+
97
+ // pnpm writes an executable shell shim instead of a symlink. Its final
98
+ // machine-readable marker is safer to verify than accepting any occurrence
99
+ // of the package path in an otherwise unrelated executable.
100
+ const shim = readFileSync(binPath, 'utf8')
101
+ assert.ok(Buffer.byteLength(shim, 'utf8') <= 32_768, 'clean-install gotry shim is unexpectedly large')
102
+ const lines = shim.trimEnd().split(/\r?\n/)
103
+ const markers = lines.filter(line => line.startsWith('# cmd-shim-target='))
104
+ assert.equal(markers.length, 1, 'pnpm gotry shim must contain exactly one target marker')
105
+ assert.equal(lines.at(-1), markers[0], 'pnpm gotry shim target marker must be the final line')
106
+ const markerTarget = markers[0].slice('# cmd-shim-target='.length)
107
+ assert.ok(isAbsolute(markerTarget), 'pnpm gotry shim target must be absolute')
108
+ assert.equal(
109
+ realpathSync(markerTarget),
110
+ resolvedExpectedTarget,
111
+ 'GOTRY_MAP_TOOLS_E2E_BIN shim must target this installed GoTry package',
112
+ )
113
+ }
114
+
115
+ function prepareProofPackage(cleanInstalledBin?: string): { packageRoot: string; proofRoot?: string } {
116
+ if (cleanInstalledBin) {
117
+ const binPath = resolve(cleanInstalledBin)
118
+ assert.equal(basename(binPath), 'gotry', 'clean-install executable must use the gotry bin name')
119
+ assert.equal(basename(dirname(binPath)), '.bin', 'clean-install executable must come from node_modules/.bin')
120
+ assert.equal(basename(dirname(dirname(binPath))), 'node_modules', 'clean-install executable must come from a consumer install')
121
+ const consumerRoot = dirname(dirname(dirname(binPath)))
122
+ const packageMain = createRequire(join(consumerRoot, 'package.json')).resolve('@danceiny/gotry')
123
+ const packageRoot = findPackageRoot(packageMain)
124
+ const installedManifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as {
125
+ name?: string
126
+ bin?: string | Record<string, string>
127
+ }
128
+ assert.equal(installedManifest.name, '@danceiny/gotry', 'clean-install executable must belong to GoTry')
129
+ const declaredBin = typeof installedManifest.bin === 'string'
130
+ ? installedManifest.bin
131
+ : installedManifest.bin?.gotry
132
+ assert.equal(declaredBin, 'bin/gotry-inner.js', 'installed package must declare the production gotry bin')
133
+ assertBinTargetsPackage(binPath, join(packageRoot, declaredBin))
134
+ return { packageRoot }
135
+ }
136
+
137
+ const proofRoot = mkdtempSync(join(repoRoot, '.tmp-map-tools-proof-'))
138
+ try {
139
+ const packOutput = execFileSync('npm', [
140
+ 'pack', '--ignore-scripts', '--json', '--pack-destination', proofRoot,
141
+ ], { cwd: repoRoot, encoding: 'utf8' })
142
+ const packInfo = JSON.parse(packOutput) as Array<{ filename: string }>
143
+ assert.equal(packInfo.length, 1, 'npm pack must produce one artifact')
144
+ const tarball = join(proofRoot, packInfo[0].filename)
145
+ assert.ok(existsSync(tarball), `packed artifact missing: ${tarball}`)
146
+ const unpackRoot = join(proofRoot, 'unpacked')
147
+ mkdirSync(unpackRoot)
148
+ execFileSync('tar', ['-xzf', tarball, '-C', unpackRoot])
149
+ const packageRoot = join(unpackRoot, 'package')
150
+ symlinkSync(join(repoRoot, 'node_modules'), join(packageRoot, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
151
+ return { packageRoot, proofRoot }
152
+ } catch (error) {
153
+ rmSync(proofRoot, { recursive: true, force: true })
154
+ throw error
155
+ }
156
+ }
157
+
158
+ const cleanInstalledBin = process.env.GOTRY_MAP_TOOLS_E2E_BIN
159
+ const { packageRoot, proofRoot } = prepareProofPackage(cleanInstalledBin)
160
+
161
+ try {
162
+ const lockedDsh = assertAlpha3LockClosure()
163
+ const vendorRoot = join(packageRoot, 'ts/dsh-runtime/vendor/dsh-map-tools')
164
+ const requiredFiles = [
165
+ 'LICENSE',
166
+ 'package.json',
167
+ 'cordis.patch.yml',
168
+ 'lib/index.js',
169
+ 'lib/settings-ns.js',
170
+ 'lib/config.js',
171
+ 'lib/tools/geocode.js',
172
+ 'lib/tools/routes.js',
173
+ 'lib/tools/poi.js',
174
+ ]
175
+ for (const relative of requiredFiles) {
176
+ assert.ok(existsSync(join(vendorRoot, relative)), `required vendor file missing: ${relative}`)
177
+ }
178
+ const vendorTree = vendorAggregate(vendorRoot)
179
+ assert.equal(vendorTree.count, upstreamVendorFileCount, 'vendored payload file count drifted')
180
+ assert.equal(vendorTree.sha256, upstreamVendorAggregateSha256, 'vendored payload aggregate drifted')
181
+ const vendorPackage = JSON.parse(readFileSync(join(vendorRoot, 'package.json'), 'utf8')) as {
182
+ name?: string
183
+ version?: string
184
+ license?: string
185
+ }
186
+ assert.equal(vendorPackage.name, 'dsh-map-tools')
187
+ assert.equal(vendorPackage.version, '0.5.1')
188
+ assert.equal(vendorPackage.license, 'MIT')
189
+ assert.equal(sha256(join(vendorRoot, 'LICENSE')), upstreamLicenseSha256, 'upstream MIT license changed')
190
+
191
+ const packageRequire = createRequire(join(packageRoot, 'package.json'))
192
+ const dshTools = JSON.parse(readFileSync(packageRequire.resolve('@deepseek-ai/dsh-tools/package.json'), 'utf8')) as { version: string }
193
+ const dshSettings = JSON.parse(readFileSync(packageRequire.resolve('@deepseek-ai/dsh-settings/package.json'), 'utf8')) as { version: string }
194
+ assert.equal(dshTools.version, '0.1.2-alpha.3')
195
+ assert.equal(dshSettings.version, '0.1.2-alpha.3')
196
+ const settingsApi = await import(pathToFileURL(packageRequire.resolve('@deepseek-ai/dsh-settings')).href) as {
197
+ SettingsProvider?: { prototype?: { installSection?: unknown } }
198
+ }
199
+ assert.equal(
200
+ typeof settingsApi.SettingsProvider?.prototype?.installSection,
201
+ 'function',
202
+ 'real alpha.3 SettingsProvider.installSection API must be present',
203
+ )
204
+
205
+ const plugin = await import(pathToFileURL(join(vendorRoot, 'lib/index.js')).href)
206
+ const registered: Array<Record<string, unknown>> = []
207
+ const settingsCalls: unknown[][] = []
208
+ const context = {
209
+ tools: {
210
+ register(tool: Record<string, unknown>) {
211
+ registered.push(tool)
212
+ return () => undefined
213
+ },
214
+ },
215
+ effect(effect: () => (() => void) | void) {
216
+ effect()
217
+ },
218
+ inject(dependencies: string[], callback: (scope: Record<string, unknown>) => void) {
219
+ if (dependencies.includes('settings')) {
220
+ callback({ settings: { installSection: (...args: unknown[]) => { settingsCalls.push(args) } } })
221
+ } else if (dependencies.includes('webServer')) {
222
+ callback({ webServer: { register: () => undefined } })
223
+ } else {
224
+ throw new Error(`unexpected dependency injection: ${dependencies.join(',')}`)
225
+ }
226
+ },
227
+ }
228
+ plugin.apply(context, {
229
+ provider: 'osm', amapKey: '', timeoutMs: 1000, maxQps: 2,
230
+ defaultMode: 'driving', language: 'zh',
231
+ })
232
+ assert.deepEqual(registered.map(tool => tool.name).sort(), expectedTools, 'exactly seven map tools must register')
233
+ assert.equal(settingsCalls.length, 1, 'alpha.3 settings section must be installed once')
234
+ assert.equal(settingsCalls[0][1], 'dsh-map-tools', 'settings namespace must be stable')
235
+ assert.equal(typeof settingsCalls[0][4], 'object', 'alpha.3 installSection hooks must be supplied')
236
+
237
+ const originalFetch = globalThis.fetch
238
+ globalThis.fetch = (async () => { throw new Error('network forbidden by package proof') }) as typeof fetch
239
+ try {
240
+ const geocode = registered.find(tool => tool.name === 'map_geocode')
241
+ assert.ok(geocode, 'map_geocode must be registered')
242
+ const result = await (geocode.execute as (args: unknown, execution: unknown) => Promise<unknown>)(
243
+ { address: '116.397428,39.90923' },
244
+ { signal: new AbortController().signal },
245
+ ) as { provider?: string; location?: number[] }
246
+ assert.equal(result.provider, 'inline')
247
+ assert.deepEqual(result.location, [116.397428, 39.90923])
248
+ } finally {
249
+ globalThis.fetch = originalFetch
250
+ }
251
+
252
+ console.log(JSON.stringify({
253
+ proof: 'map-tools-vendor-package',
254
+ status: 'PASS',
255
+ artifactMode: cleanInstalledBin ? 'clean-installed-tarball' : 'packed-unpacked-focused',
256
+ vendored: {
257
+ sourcePackage: 'dsh-map-tools@0.5.1',
258
+ licenseSha256: upstreamLicenseSha256, vendorFileCount: vendorTree.count,
259
+ vendorAggregateSha256: vendorTree.sha256,
260
+ },
261
+ artifactVendor: vendorRoot,
262
+ dshClosure: { tools: dshTools.version, settings: dshSettings.version, ...lockedDsh },
263
+ tools: registered.map(tool => tool.name).sort(),
264
+ inlineCoordinate: { provider: 'inline', network: 'forbidden' },
265
+ }, null, 2))
266
+ } finally {
267
+ if (proofRoot) rmSync(proofRoot, { recursive: true, force: true })
268
+ }
@@ -22,10 +22,38 @@ export const BENCHMARK_CONFORMANCE_STATE_UNAVAILABLE = 'BENCHMARK_CONFORMANCE_ST
22
22
 
23
23
  const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]*$/
24
24
  const MAX_TERMINAL_BYTES = 1024 * 1024
25
+ const BENCHMARK_TOOL_RESULT_SCHEMA_VERSION = 'gotry_benchmark_tool_result_v1'
26
+ const BENCHMARK_DOMAIN_RECOVERIES = new Set(['none', 'retry_same', 'revise_arguments', 'choose_alternative'])
27
+
28
+ // Round 12(issue #215):terminal body schema 是「无数据值的 closed 结构合同」——
29
+ // 只允许结构关键字(type/properties/required/additionalProperties/items),enum/
30
+ // const/example/default 等可夹带数据值的注解面一律禁止,对象必须 additionalProperties:
31
+ // false,大小/深度/节点数有界。校验语义 fail-closed:不补键、不删多余键、不转类型。
32
+ const TERMINAL_BODY_SCHEMA_MAX_BYTES = 16 * 1024
33
+ const TERMINAL_BODY_SCHEMA_MAX_DEPTH = 8
34
+ const TERMINAL_BODY_SCHEMA_MAX_NODES = 256
35
+ const TERMINAL_BODY_SCHEMA_MAX_PROPERTIES = 256
36
+ const TERMINAL_BODY_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null'])
37
+ const TERMINAL_BODY_VALUE_MAX_NODES = 10_000
38
+ const TERMINAL_BODY_VALUE_MAX_DEPTH = 24
39
+
40
+ /** Structural-only, closed JSON schema for the terminal body (no data-bearing keywords). */
41
+ export type TerminalBodySchema = {
42
+ type: 'object'
43
+ properties: Record<string, TerminalBodySchema>
44
+ required: string[]
45
+ additionalProperties: false
46
+ } | {
47
+ type: 'array'
48
+ items: TerminalBodySchema
49
+ } | {
50
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'null'
51
+ }
25
52
 
26
53
  export interface TerminalOutputConfig {
27
54
  tag: string
28
55
  max_bytes: number
56
+ body_schema: TerminalBodySchema
29
57
  }
30
58
 
31
59
  export type TerminalOutputValue =
@@ -46,6 +74,8 @@ interface TurnState {
46
74
  retryCount: number
47
75
  retryMode: RetryMode
48
76
  successfulResultStep?: number
77
+ domainOutcomeStep?: number
78
+ lastBridgeResponseStep?: number
49
79
  bridgeCallFailed: boolean
50
80
  bridgeFailureCode?: string
51
81
  retryRedispatchAttempted: boolean
@@ -76,13 +106,113 @@ function plainObject(value: unknown): value is Record<string, unknown> {
76
106
  export function validateTerminalOutputConfig(value: unknown): value is TerminalOutputConfig {
77
107
  if (!plainObject(value)) return false
78
108
  const keys = Object.keys(value).sort()
79
- return JSON.stringify(keys) === JSON.stringify(['max_bytes', 'tag'])
109
+ return JSON.stringify(keys) === JSON.stringify(['body_schema', 'max_bytes', 'tag'])
80
110
  && typeof value.tag === 'string'
81
111
  && IDENTIFIER.test(value.tag)
82
112
  && typeof value.max_bytes === 'number'
83
113
  && Number.isInteger(value.max_bytes)
84
114
  && value.max_bytes >= 1
85
115
  && value.max_bytes <= MAX_TERMINAL_BYTES
116
+ && validateTerminalBodySchema(value.body_schema)
117
+ }
118
+
119
+ /** Accept only the closed structural dialect: exact keyword sets per node, bounded size. */
120
+ export function validateTerminalBodySchema(value: unknown): value is TerminalBodySchema {
121
+ let serialized: string
122
+ try {
123
+ serialized = JSON.stringify(value)
124
+ } catch {
125
+ return false
126
+ }
127
+ if (typeof serialized !== 'string' || Buffer.byteLength(serialized, 'utf8') > TERMINAL_BODY_SCHEMA_MAX_BYTES) return false
128
+ const pending: Array<{ node: unknown; depth: number }> = [{ node: value, depth: 0 }]
129
+ const seen = new Set<object>()
130
+ let nodes = 0
131
+ let properties = 0
132
+ while (pending.length > 0) {
133
+ const { node, depth } = pending.pop()!
134
+ if (!plainObject(node) || seen.has(node) || ++nodes > TERMINAL_BODY_SCHEMA_MAX_NODES || depth > TERMINAL_BODY_SCHEMA_MAX_DEPTH) return false
135
+ seen.add(node)
136
+ const type = node.type
137
+ if (type === 'object') {
138
+ if (!exactKeys(node, ['type', 'properties', 'required', 'additionalProperties'])
139
+ || !plainObject(node.properties)
140
+ || !Array.isArray(node.required)
141
+ || node.additionalProperties !== false) return false
142
+ const entries = Object.entries(node.properties)
143
+ properties += entries.length
144
+ if (properties > TERMINAL_BODY_SCHEMA_MAX_PROPERTIES
145
+ || entries.some(([key, child]) => key.length === 0 || key.length > 64 || !plainObject(child))) return false
146
+ if (node.required.length > entries.length
147
+ || new Set(node.required).size !== node.required.length
148
+ || node.required.some(key => typeof key !== 'string' || !Object.hasOwn(node.properties as Record<string, unknown>, key))) return false
149
+ for (const [, child] of entries) pending.push({ node: child, depth: depth + 1 })
150
+ continue
151
+ }
152
+ if (type === 'array') {
153
+ if (!exactKeys(node, ['type', 'items']) || !plainObject(node.items)) return false
154
+ pending.push({ node: node.items, depth: depth + 1 })
155
+ continue
156
+ }
157
+ if (typeof type !== 'string' || !TERMINAL_BODY_SCALAR_TYPES.has(type) || !exactKeys(node, ['type'])) return false
158
+ }
159
+ return plainObject(value) && value.type === 'object'
160
+ }
161
+
162
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
163
+ return Object.keys(value).every(key => allowed.includes(key))
164
+ }
165
+
166
+ /** Strict structural validation: exact keys, exact types, no coercion, no autofix. */
167
+ export function validateTerminalBodyValue(value: unknown, schema: TerminalBodySchema): boolean {
168
+ const pending: Array<{ value: unknown; schema: TerminalBodySchema; depth: number }> = [{ value, schema, depth: 0 }]
169
+ let nodes = 0
170
+ while (pending.length > 0) {
171
+ const current = pending.pop()!
172
+ if (++nodes > TERMINAL_BODY_VALUE_MAX_NODES || current.depth > TERMINAL_BODY_VALUE_MAX_DEPTH) return false
173
+ const { schema: node } = current
174
+ if (node.type === 'object') {
175
+ const record = current.value
176
+ if (!plainObject(record)) return false
177
+ const properties = node.properties as Record<string, TerminalBodySchema>
178
+ for (const key of node.required) {
179
+ if (!Object.hasOwn(record, key)) return false
180
+ }
181
+ for (const [key, child] of Object.entries(record)) {
182
+ const childSchema = properties[key]
183
+ if (!childSchema) return false
184
+ pending.push({ value: child, schema: childSchema, depth: current.depth + 1 })
185
+ }
186
+ continue
187
+ }
188
+ if (node.type === 'array') {
189
+ if (!Array.isArray(current.value)) return false
190
+ for (const item of current.value) pending.push({ value: item, schema: node.items, depth: current.depth + 1 })
191
+ continue
192
+ }
193
+ if (node.type === 'string') { if (typeof current.value !== 'string') return false; continue }
194
+ if (node.type === 'boolean') { if (typeof current.value !== 'boolean') return false; continue }
195
+ if (node.type === 'null') { if (current.value !== null) return false; continue }
196
+ if (node.type === 'integer') { if (typeof current.value !== 'number' || !Number.isSafeInteger(current.value)) return false; continue }
197
+ if (typeof current.value !== 'number' || !Number.isFinite(current.value)) return false
198
+ }
199
+ return true
200
+ }
201
+
202
+ /** Deterministic single-source structural outline projected into system prompt and correction alike. */
203
+ export function terminalSchemaOutline(schema: TerminalBodySchema): string {
204
+ const render = (node: TerminalBodySchema, depth: number): string => {
205
+ if (depth > TERMINAL_BODY_SCHEMA_MAX_DEPTH) return '…'
206
+ if (node.type === 'object') {
207
+ const properties = node.properties as Record<string, TerminalBodySchema>
208
+ const required = new Set(node.required)
209
+ const parts = Object.entries(properties).map(([key, child]) => `${required.has(key) ? '' : '?'}${key}:${render(child, depth + 1)}`)
210
+ return `object{${parts.join(',')}}`
211
+ }
212
+ if (node.type === 'array') return `array<${render(node.items, depth + 1)}>`
213
+ return node.type
214
+ }
215
+ return render(schema, 0)
86
216
  }
87
217
 
88
218
  function invalidTerminal(): TerminalOutputValue {
@@ -112,7 +242,11 @@ export function parseBenchmarkTerminal(raw: string, config: TerminalOutputConfig
112
242
 
113
243
  try {
114
244
  const value: unknown = JSON.parse(body)
115
- return plainObject(value) ? { ok: true, value } : invalidTerminal()
245
+ if (!plainObject(value)) return invalidTerminal()
246
+ // Round 12(#215):同一份 closed body schema 在接受终态前 fail-closed 校验——
247
+ // 结构不合法的终态永不进入正式计分链,不做任何 autofix。
248
+ if (!validateTerminalBodyValue(value, config.body_schema)) return invalidTerminal()
249
+ return { ok: true, value }
116
250
  } catch {
117
251
  return invalidTerminal()
118
252
  }
@@ -150,7 +284,9 @@ function assistantText(data: Record<string, unknown>): { text: string; interrupt
150
284
  return { text, interrupted: data.interrupted === true }
151
285
  }
152
286
 
153
- function bridgeResultStatus(data: Record<string, unknown>, callId: string): { ok: true } | { ok: false; code: string } | undefined {
287
+ type BridgeResultStatus = { kind: 'result' } | { kind: 'domain' } | { kind: 'failure'; code: string }
288
+
289
+ function bridgeResultStatus(data: Record<string, unknown>, callId: string): BridgeResultStatus | undefined {
154
290
  if (!plainObject(data.message) || !plainObject(data.message.source)) return undefined
155
291
  if (data.message.source.callId !== callId || !Array.isArray(data.message.content)) return undefined
156
292
  const block = data.message.content.find(candidate => plainObject(candidate)
@@ -164,8 +300,28 @@ function bridgeResultStatus(data: Record<string, unknown>, callId: string): { ok
164
300
  try {
165
301
  const parsed: unknown = JSON.parse(text)
166
302
  if (!plainObject(parsed)) return undefined
167
- if (parsed.ok === true) return { ok: true }
303
+ const keys = Object.keys(parsed).sort().join(',')
304
+ if (parsed.ok === true && keys === 'ok,result' && (plainObject(parsed.result) || Array.isArray(parsed.result))) return { kind: 'result' }
305
+ if (parsed.ok === true && keys === 'ok,outcome' && plainObject(parsed.outcome)) {
306
+ const outcomeKeys = Object.keys(parsed.outcome).sort().join(',')
307
+ if (outcomeKeys === 'code,recovery,schema_version,status'
308
+ && parsed.outcome.schema_version === BENCHMARK_TOOL_RESULT_SCHEMA_VERSION
309
+ && (parsed.outcome.status === 'miss' || parsed.outcome.status === 'error')
310
+ && typeof parsed.outcome.code === 'string'
311
+ && IDENTIFIER.test(parsed.outcome.code)
312
+ && typeof parsed.outcome.recovery === 'string'
313
+ && BENCHMARK_DOMAIN_RECOVERIES.has(parsed.outcome.recovery)) return { kind: 'domain' }
314
+ return undefined
315
+ }
168
316
  if (parsed.ok === false && typeof parsed.error === 'string') {
317
+ const exactFailure = parsed.error === 'runner_failed'
318
+ ? (keys === 'error,ok' || (keys === 'error,exit_code,ok,signal'
319
+ && (parsed.exit_code === null || (typeof parsed.exit_code === 'number' && Number.isInteger(parsed.exit_code)))
320
+ && (parsed.signal === null || typeof parsed.signal === 'string')))
321
+ : parsed.error === 'invalid_arguments'
322
+ ? keys === 'error,ok,reason' && typeof parsed.reason === 'string'
323
+ : keys === 'error,ok'
324
+ if (!exactFailure) return undefined
169
325
  const code = parsed.error === 'timed_out'
170
326
  ? BENCHMARK_BRIDGE_TIMED_OUT
171
327
  : parsed.error === 'runner_failed'
@@ -175,7 +331,7 @@ function bridgeResultStatus(data: Record<string, unknown>, callId: string): { ok
175
331
  : parsed.error === 'output_truncated'
176
332
  ? BENCHMARK_BRIDGE_OUTPUT_TRUNCATED
177
333
  : BENCHMARK_BRIDGE_CALL_FAILED
178
- return { ok: false, code }
334
+ return { kind: 'failure', code }
179
335
  }
180
336
  return undefined
181
337
  } catch {
@@ -249,10 +405,15 @@ export function createBenchmarkAgentConformance(projection: BenchmarkBridgeProje
249
405
  const callId = event.data.message.source.callId
250
406
  if (typeof callId !== 'string' || !state.validCallIds.has(callId)) return
251
407
  const resultStatus = bridgeResultStatus(event.data, callId)
252
- if (resultStatus?.ok === true) {
408
+ if (typeof event.data.step === 'number') state.lastBridgeResponseStep = event.data.step
409
+ if (resultStatus?.kind === 'result') {
253
410
  state.successfulResultStep = typeof event.data.step === 'number'
254
411
  ? event.data.step
255
412
  : state.successfulResultStep
413
+ } else if (resultStatus?.kind === 'domain') {
414
+ state.domainOutcomeStep = typeof event.data.step === 'number'
415
+ ? event.data.step
416
+ : state.domainOutcomeStep
256
417
  } else {
257
418
  state.bridgeCallFailed = true
258
419
  state.bridgeFailureCode = resultStatus?.code ?? BENCHMARK_BRIDGE_CALL_FAILED
@@ -274,10 +435,10 @@ export function createBenchmarkAgentConformance(projection: BenchmarkBridgeProje
274
435
  if (state.retryRedispatchAttempted) {
275
436
  return { kind: 'reject', code: BENCHMARK_BRIDGE_RETRY_CALL_NOT_ALLOWED }
276
437
  }
277
- if (state.successfulResultStep === undefined) {
278
- if (state.bridgeCallFailed) {
279
- return { kind: 'reject', code: state.bridgeFailureCode ?? BENCHMARK_BRIDGE_CALL_FAILED }
280
- }
438
+ if (state.bridgeCallFailed && state.successfulResultStep === undefined) {
439
+ return { kind: 'reject', code: state.bridgeFailureCode ?? BENCHMARK_BRIDGE_CALL_FAILED }
440
+ }
441
+ if (state.successfulResultStep === undefined && state.domainOutcomeStep === undefined) {
281
442
  if (state.retryCount >= MAX_CONFORMANCE_RETRIES) {
282
443
  return { kind: 'reject', code: BENCHMARK_BRIDGE_CALL_REQUIRED }
283
444
  }
@@ -289,7 +450,7 @@ export function createBenchmarkAgentConformance(projection: BenchmarkBridgeProje
289
450
  const terminal = state.lastAssistant
290
451
  const terminalIsValid = terminal !== undefined
291
452
  && !terminal.interrupted
292
- && terminal.step > state.successfulResultStep
453
+ && terminal.step > state.lastBridgeResponseStep!
293
454
  && parseBenchmarkTerminal(terminal.text, projection.terminal).ok
294
455
  if (terminalIsValid) return { kind: 'accept' }
295
456
  if (state.retryCount >= MAX_CONFORMANCE_RETRIES) {
@@ -332,7 +493,7 @@ function requireDisposer(value: unknown, capability: string): Disposer {
332
493
  function correctionMessage(mode: Exclude<RetryMode, 'none'>, projection: BenchmarkBridgeProjection) {
333
494
  const text = mode === 'call'
334
495
  ? `BENCHMARK_CONFORMANCE_CALL: Execute exactly one native ${projection.toolName} action:"call" now. Describing an intended CLI, shell, or Python command does not execute it.`
335
- : `BENCHMARK_CONFORMANCE_TERMINAL: Reuse the existing successful tool result. Do not call any tool. Reply only <${projection.terminal.tag}>{"result":"..."}</${projection.terminal.tag}> with one JSON object.`
496
+ : `BENCHMARK_CONFORMANCE_TERMINAL: Reuse the existing successful tool result. Do not call any tool. Reply only <${projection.terminal.tag}> with one JSON object matching exactly ${terminalSchemaOutline(projection.terminal.body_schema)} no extra keys, no missing keys, exact types.`
336
497
  return createUserMessage({
337
498
  content: [{ type: 'text' as const, text }],
338
499
  source: { kind: 'plugin' as const, plugin: 'gotry-benchmark-agent-conformance' },
@@ -341,6 +502,7 @@ function correctionMessage(mode: Exclude<RetryMode, 'none'>, projection: Benchma
341
502
 
342
503
  function systemSection(projection: BenchmarkBridgeProjection): { name: string; text: string } {
343
504
  const allowed = projection.allowedTools.join(', ')
505
+ const outline = terminalSchemaOutline(projection.terminal.body_schema)
344
506
  return {
345
507
  name: 'benchmark:agent-conformance',
346
508
  text: [
@@ -348,7 +510,7 @@ function systemSection(projection: BenchmarkBridgeProjection): { name: string; t
348
510
  `- Translate every task instruction to use a CLI, shell, Python, or agent_env.cli into the native tool ${projection.toolName}; do not merely describe the intended command.`,
349
511
  `- Call it with exactly {"action":"call","tool":"<one of: ${allowed}>","arguments":{...}}.`,
350
512
  '- action:"tools" is discovery only and does not satisfy the required environment call.',
351
- `- After a successful tool result, reply only <${projection.terminal.tag}>{...one JSON object...}</${projection.terminal.tag}> with no prose or code fence.`,
513
+ `- After a successful tool result, reply only <${projection.terminal.tag}> with one JSON object matching exactly ${outline} no extra keys, no missing keys, exact types; no prose or code fence.`,
352
514
  '- If a terminal-format correction arrives, reuse the existing result and do not call the tool again.',
353
515
  ].join('\n'),
354
516
  }