@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.20

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 (176) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +309 -30
  4. package/bin/gotry-inner.js +74 -23
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +22 -8
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/anything.js +62 -29
  11. package/dist/capabilities/channel-health.js +103 -0
  12. package/dist/capabilities/channel-registry.js +213 -0
  13. package/dist/capabilities/doctor.js +307 -0
  14. package/dist/capabilities/effect.js +178 -5
  15. package/dist/capabilities/flyai.js +13 -0
  16. package/dist/capabilities/hbcli.js +28 -2
  17. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  18. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  19. package/dist/capabilities/session/extension-bridge.js +14 -1
  20. package/dist/capabilities/session/extension-channel.js +4 -2
  21. package/dist/capabilities/session/extension-distribution.js +1 -1
  22. package/dist/capabilities/session-search.js +311 -0
  23. package/dist/capabilities/visa-policy.js +126 -0
  24. package/dist/scripts/agent-planning-turn-deadline-e2e.js +305 -0
  25. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  26. package/dist/scripts/anything-tests.js +70 -21
  27. package/dist/scripts/benchmark-environment-bridge-e2e.js +139 -37
  28. package/dist/scripts/benchmark-environment-bridge-tests.js +1099 -306
  29. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  30. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  31. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  32. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  33. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  34. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  35. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  36. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  37. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  38. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  39. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  40. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  41. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  42. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  43. package/dist/scripts/bootstrap-tests.js +113 -2
  44. package/dist/scripts/build-metrics-report.js +339 -0
  45. package/dist/scripts/channel-probe-tests.js +148 -0
  46. package/dist/scripts/channel-probe.js +252 -0
  47. package/dist/scripts/channel-registry-tests.js +262 -0
  48. package/dist/scripts/doctor-tests.js +175 -0
  49. package/dist/scripts/effect-tests.js +274 -1
  50. package/dist/scripts/extension-tests.js +129 -11
  51. package/dist/scripts/fact-gate-tests.js +91 -2
  52. package/dist/scripts/flyai-tests.js +17 -1
  53. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  54. package/dist/scripts/hbcli-tests.js +38 -1
  55. package/dist/scripts/metrics-report-tests.js +288 -0
  56. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  57. package/dist/scripts/session-tests.js +401 -1
  58. package/dist/scripts/smoke.js +100 -83
  59. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  60. package/dist/scripts/turn-handoff-collect.js +189 -0
  61. package/dist/scripts/turn-policy-tests.js +56 -0
  62. package/dist/scripts/typed-contract-canary.js +261 -0
  63. package/dist/scripts/visa-policy-tests.js +75 -0
  64. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  65. package/dist/src/artifact-gate.js +91 -3
  66. package/dist/src/benchmark-agent-conformance.js +39 -16
  67. package/dist/src/benchmark-environment-bridge.js +316 -52
  68. package/dist/src/bookable-facts.js +62 -3
  69. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  70. package/dist/src/booking-surface/canonical-schema.js +8 -22
  71. package/dist/src/booking-surface/contracts.js +70 -19
  72. package/dist/src/booking-surface/dsh-planner.js +310 -177
  73. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  74. package/dist/src/booking-surface/index.js +1 -5
  75. package/dist/src/booking-surface/profile.js +1 -1
  76. package/dist/src/booking-surface/runtime.js +1682 -247
  77. package/dist/src/booking-surface/server.js +371 -185
  78. package/dist/src/booking-surface/startup.js +17 -27
  79. package/dist/src/booking-surface/validation.js +270 -760
  80. package/dist/src/index.js +739 -150
  81. package/dist/src/turn-deadline.js +292 -0
  82. package/dist/src/turn-policy.js +146 -0
  83. package/dist/src/wish-pool.js +5 -0
  84. package/extension/background.js +23 -4
  85. package/extension/content-main.js +47 -9
  86. package/extension/manifest.json +27 -9
  87. package/package.json +7 -26
  88. package/schemas/booking.surface.schema.json +2593 -0
  89. package/ts/capabilities/agent-reach.ts +4 -4
  90. package/ts/capabilities/anything.ts +89 -38
  91. package/ts/capabilities/flyai.ts +21 -3
  92. package/ts/capabilities/hbcli.ts +53 -4
  93. package/ts/capabilities/session/action-cache.ts +1 -1
  94. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  95. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  96. package/ts/capabilities/session/extension-bridge.ts +32 -2
  97. package/ts/capabilities/session/extension-channel.ts +9 -8
  98. package/ts/capabilities/session/extension-distribution.ts +3 -1
  99. package/ts/capabilities/session/health-watch.ts +1 -1
  100. package/ts/capabilities/session/read-guard.ts +1 -1
  101. package/ts/capabilities/session/wizard.ts +1 -1
  102. package/ts/capabilities/session-search.ts +323 -1
  103. package/ts/dsh-runtime/vendor/README.md +60 -0
  104. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  105. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
  106. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
  107. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
  108. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  109. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
  110. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  111. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
  112. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  113. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  114. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
  115. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  116. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
  117. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
  118. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  119. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  120. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
  121. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
  122. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  123. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  124. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  125. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  126. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
  127. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  128. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  129. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  130. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  131. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  132. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
  133. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
  134. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  135. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
  136. package/ts/package.json +9 -0
  137. package/ts/scripts/turn-handoff-collect.ts +178 -0
  138. package/ts/src/artifact-gate.ts +82 -2
  139. package/ts/src/benchmark-agent-conformance.ts +54 -15
  140. package/ts/src/benchmark-environment-bridge.ts +234 -46
  141. package/ts/src/bookable-facts.ts +109 -5
  142. package/ts/src/booking-saga.ts +1 -1
  143. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  144. package/ts/src/booking-surface/canonical-schema.js +8 -22
  145. package/ts/src/booking-surface/contracts.ts +278 -239
  146. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  147. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  148. package/ts/src/booking-surface/index.ts +1 -5
  149. package/ts/src/booking-surface/profile.ts +11 -11
  150. package/ts/src/booking-surface/runtime.ts +1351 -351
  151. package/ts/src/booking-surface/server.ts +282 -175
  152. package/ts/src/booking-surface/startup.ts +36 -48
  153. package/ts/src/booking-surface/validation.ts +194 -442
  154. package/ts/src/contracts.ts +1 -1
  155. package/ts/src/index.ts +492 -172
  156. package/ts/src/loop.ts +1 -1
  157. package/ts/src/state-ledger.ts +1 -1
  158. package/ts/src/turn-deadline.ts +365 -0
  159. package/ts/src/turn-policy.ts +154 -0
  160. package/ts/src/wish-pool.ts +17 -4
  161. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  162. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  163. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  164. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  165. package/dist/src/booking-surface/contracts-v2.js +0 -89
  166. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  167. package/dist/src/booking-surface/server-v2.js +0 -334
  168. package/dist/src/booking-surface/validation-v2.js +0 -319
  169. package/dist/src/tool-budget.js +0 -136
  170. package/schemas/booking.surface.v1.schema.json +0 -927
  171. package/schemas/booking.surface.v2.schema.json +0 -61
  172. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  173. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  174. package/ts/src/booking-surface/server-v2.ts +0 -247
  175. package/ts/src/booking-surface/validation-v2.ts +0 -205
  176. package/ts/src/tool-budget.ts +0 -165
package/ts/src/loop.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Stage 1 对话循环(S2 段1,docs/stage1-top-down-design.md §2.3)。
2
+ * Stage 1 对话循环(S2 段1,docs/design/stage1-top-down-design.md §2.3)。
3
3
  *
4
4
  * 自顶向下:循环只依赖两个端口——LlmPort(mock/真)与确定性工具(interview_next)。
5
5
  * 求解/渲染接线是 S2 后续段;本段验收:重放用户开场白,系统一轮内完成
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 事务化状态账本(ADR-15,docs/transactional-state-rfc.md):
2
+ * 事务化状态账本(ADR-15,docs/rfc/transactional-state-rfc.md):
3
3
  * 「文件即权威」→「单文件 SQLite 账本即权威」。
4
4
  *
5
5
  * - events 表 = 唯一权威(append-only;语义幂等键物理化为 UNIQUE 索引)
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Per-agent, per-turn wall-clock boundary driven by routing (ADR-24 v2).
3
+ *
4
+ * 职责只有一件:保证每一轮终结在三态之一——当面答完(converge)、转后台
5
+ * 并给出回访承诺(handoff)、或明确暂停。绝不允许第四态:流死掉、什么
6
+ * 都没交付(2026-09-02 轨迹的失败形态)。
7
+ *
8
+ * - 阈值不再固定:每次 user/message 由 `turn-policy.ts` 的确定性分类器
9
+ * 解析 TurnPolicy(softMs/hardMs/exit);benchmark opt-in 用 fixedPolicy
10
+ * 钉死以保证可复现。
11
+ * - 硬阈拒绝时**同步**抑制工具 schema(防同 step 回环),并按 exit 语义
12
+ * 返回两种结构化结果:converge=用已有证据作答;handoff=落
13
+ * `gotry_turn_handoff.v1` 工单并指令模型告知用户 ETA 与回访方式。
14
+ * - handoff 工单是**新的独立格式**,落在 `<stateRoot>/gotry-state/
15
+ * turn-handoffs/`,不与 `async/` 的 loop 求解工单混用——async-collect
16
+ * 对无 spec 的 state 会立即结算 failed(假失败),收集器是未来的
17
+ * agent tick(loopx),不是 async-collect。
18
+ */
19
+
20
+ import { randomUUID } from 'node:crypto'
21
+ import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
22
+ import { join } from 'node:path'
23
+ import type { Context } from '@deepseek-ai/cordis'
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'
29
+ import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
30
+ import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor, type TurnPolicy } from './turn-policy.ts'
31
+
32
+ export const TURN_DEADLINE_SOFT = 'TURN_DEADLINE_SOFT'
33
+ export const TURN_DEADLINE_EXHAUSTED = 'TURN_DEADLINE_EXHAUSTED'
34
+ export const TURN_DEADLINE_HANDOFF = 'TURN_DEADLINE_HANDOFF'
35
+ export const TURN_HANDOFF_SCHEMA = 'gotry_turn_handoff.v1' as const
36
+
37
+ export interface TurnHandoffTicket {
38
+ schema: typeof TURN_HANDOFF_SCHEMA
39
+ id: string
40
+ objective: string
41
+ userMessage: string
42
+ requestedAt: string
43
+ etaLabel: string
44
+ status: 'open' | 'settled' | 'failed'
45
+ /** 结算时间(ISO);open 时缺省。 */
46
+ settledAt?: string
47
+ /** 结算交付物文件名(与工单同目录的 .deliverable.md)。 */
48
+ deliverableFile?: string
49
+ /** failed 时的稳定错误描述。 */
50
+ error?: string
51
+ }
52
+
53
+ /** 复访视图:工单事实 + 已结算交付物摘录(有界)。 */
54
+ export interface TurnHandoffTicketView {
55
+ id: string
56
+ status: TurnHandoffTicket['status']
57
+ objective: string
58
+ requestedAt: string
59
+ etaLabel: string
60
+ settledAt?: string
61
+ error?: string
62
+ deliverableExcerpt?: string
63
+ }
64
+
65
+ export interface TurnDeadlineOptions {
66
+ /** When false the install call returns immediately with zero side effects. */
67
+ enabled?: boolean
68
+ /** Benchmark pin: routing is bypassed, every turn gets this exact policy. */
69
+ fixedPolicy?: TurnPolicy
70
+ /** Product: handoff ticket target root. Handoff degrades to converge when absent. */
71
+ stateRoot?: string
72
+ /** Wall-clock source — overridden by tests for determinism. */
73
+ now?: () => number
74
+ }
75
+
76
+ export async function writeTurnHandoffTicket(stateRoot: string, userMessage: string): Promise<TurnHandoffTicket> {
77
+ const ticket: TurnHandoffTicket = {
78
+ schema: TURN_HANDOFF_SCHEMA,
79
+ id: `th-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`,
80
+ objective: userMessage.length > 120 ? `${userMessage.slice(0, 120)}…` : userMessage,
81
+ userMessage,
82
+ requestedAt: new Date().toISOString(),
83
+ etaLabel: TURN_HANDOFF_ETA_LABEL,
84
+ status: 'open',
85
+ }
86
+ await writeTicketJson(stateRoot, ticket)
87
+ return ticket
88
+ }
89
+
90
+ function handoffDir(stateRoot: string): string {
91
+ const root = stateRoot === '.' ? process.cwd() : stateRoot
92
+ return join(root, 'gotry-state', 'turn-handoffs')
93
+ }
94
+
95
+ async function writeTicketJson(stateRoot: string, ticket: TurnHandoffTicket): Promise<string> {
96
+ const dir = handoffDir(stateRoot)
97
+ await mkdir(dir, { recursive: true })
98
+ const path = join(dir, `${ticket.id}.json`)
99
+ const tmp = `${path}.tmp`
100
+ await writeFile(tmp, JSON.stringify(ticket, null, 2), 'utf-8')
101
+ await rename(tmp, path)
102
+ return path
103
+ }
104
+
105
+ /**
106
+ * 结算工单:交付物落 `<id>.deliverable.md`(settled=规划产物;failed=诚实
107
+ * 失败说明),工单 json 原子更新 status/settledAt/deliverableFile/error。
108
+ */
109
+ export async function settleTurnHandoffTicket(
110
+ stateRoot: string,
111
+ ticket: TurnHandoffTicket,
112
+ status: 'settled' | 'failed',
113
+ deliverable: string,
114
+ error?: string,
115
+ ): Promise<{ ticketPath: string; deliverablePath: string }> {
116
+ const dir = handoffDir(stateRoot)
117
+ await mkdir(dir, { recursive: true })
118
+ const deliverablePath = join(dir, `${ticket.id}.deliverable.md`)
119
+ const deliverableTmp = `${deliverablePath}.tmp`
120
+ await writeFile(deliverableTmp, deliverable, 'utf-8')
121
+ await rename(deliverableTmp, deliverablePath)
122
+ const settled: TurnHandoffTicket = {
123
+ ...ticket,
124
+ status,
125
+ settledAt: new Date().toISOString(),
126
+ deliverableFile: `${ticket.id}.deliverable.md`,
127
+ ...(error ? { error } : {}),
128
+ }
129
+ const ticketPath = await writeTicketJson(stateRoot, settled)
130
+ return { ticketPath, deliverablePath }
131
+ }
132
+
133
+ /** 复访查询:按请求时间倒序列出工单;settled 附交付物摘录(≤600 字)。 */
134
+ export async function listTurnHandoffTickets(stateRoot: string): Promise<TurnHandoffTicketView[]> {
135
+ const dir = handoffDir(stateRoot)
136
+ let names: string[]
137
+ try {
138
+ names = await readdir(dir)
139
+ } catch {
140
+ return []
141
+ }
142
+ const views: TurnHandoffTicketView[] = []
143
+ for (const name of names) {
144
+ if (!name.endsWith('.json')) continue
145
+ try {
146
+ const raw = JSON.parse(await readFile(join(dir, name), 'utf-8')) as TurnHandoffTicket
147
+ if (raw?.schema !== TURN_HANDOFF_SCHEMA) continue
148
+ const view: TurnHandoffTicketView = {
149
+ id: raw.id,
150
+ status: raw.status,
151
+ objective: raw.objective,
152
+ requestedAt: raw.requestedAt,
153
+ etaLabel: raw.etaLabel,
154
+ ...(raw.settledAt ? { settledAt: raw.settledAt } : {}),
155
+ ...(raw.error ? { error: raw.error } : {}),
156
+ }
157
+ if (raw.status !== 'open' && raw.deliverableFile) {
158
+ try {
159
+ const text = await readFile(join(dir, raw.deliverableFile), 'utf-8')
160
+ view.deliverableExcerpt = text.length > 600 ? `${text.slice(0, 600)}…` : text
161
+ } catch { /* 交付物缺失时视图降级,不阻塞列表 */ }
162
+ }
163
+ views.push(view)
164
+ } catch { /* 单个坏工单不阻塞列表 */ }
165
+ }
166
+ views.sort((a, b) => (a.requestedAt < b.requestedAt ? 1 : -1))
167
+ return views
168
+ }
169
+
170
+ function resolveEnvPin(name: string): number | null {
171
+ const raw = process.env[name]
172
+ if (!raw) return null
173
+ const parsed = Number.parseInt(raw, 10)
174
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
175
+ }
176
+
177
+ function convergenceContext(code: string, text: string) {
178
+ return {
179
+ id: randomUUID(),
180
+ role: 'user' as const,
181
+ content: [{ type: 'text' as const, text: `${code}: ${text}` }],
182
+ source: { kind: 'plugin' as const, plugin: 'gotry-turn-deadline' },
183
+ }
184
+ }
185
+
186
+ function exhaustionResult(reason: string): ToolExecutionResult {
187
+ return {
188
+ isError: true,
189
+ error: {
190
+ message: reason,
191
+ info: { name: 'TurnDeadlineError', code: TURN_DEADLINE_EXHAUSTED },
192
+ },
193
+ content: [{ type: 'text', text: reason }],
194
+ additionalContexts: [convergenceContext(
195
+ TURN_DEADLINE_EXHAUSTED,
196
+ 'No more tool dispatches are permitted in this turn. Produce the final answer from existing results.',
197
+ ) as never],
198
+ }
199
+ }
200
+
201
+ function handoffResult(ticket: TurnHandoffTicket): ToolExecutionResult {
202
+ const instruction = `Turn handed off to background planning. Async ticket ${ticket.id} created (ETA ${ticket.etaLabel}). Stop dispatching tools and tell the user in one short message: the deep planning continues in the background, the expected time, and to check back later (mention ticket ${ticket.id}).`
203
+ return {
204
+ isError: true,
205
+ error: {
206
+ message: `${TURN_DEADLINE_HANDOFF}: sync window exhausted for a deep-planning turn; ticket ${ticket.id}`,
207
+ info: { name: 'TurnDeadlineHandoff', code: TURN_DEADLINE_HANDOFF },
208
+ },
209
+ content: [{ type: 'text', text: `${TURN_DEADLINE_HANDOFF}: ${instruction}` }],
210
+ additionalContexts: [convergenceContext(TURN_DEADLINE_HANDOFF, instruction) as never],
211
+ }
212
+ }
213
+
214
+ type RestrictableTools = {
215
+ schemas?: () => Array<{ name: string }>
216
+ restrict?: (filter: { deny: string[] }) => () => void
217
+ }
218
+
219
+ type AgentTurnState = {
220
+ startedAt: number
221
+ policy: TurnPolicy
222
+ userMessage?: string
223
+ }
224
+
225
+ /**
226
+ * Install the runtime boundary, kept separate from the GoTry tool catalog so
227
+ * the actual Cordis waterfalls can be exercised without booting every tool.
228
+ */
229
+ export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions = {}): void {
230
+ if (options.enabled === false) return
231
+
232
+ // Some offline contract tests intentionally provide a registration-only
233
+ // Context slice. They do not drive an agent loop, so there is no boundary
234
+ // to install and no reason to require an event bus.
235
+ if (typeof (ctx as unknown as { on?: unknown }).on !== 'function') return
236
+
237
+ const now = options.now ?? (() => Date.now())
238
+ // Test/benchmark seam only: pin the resolved numbers without touching the
239
+ // routed exit strategy. Product runs never set these.
240
+ const softPin = resolveEnvPin('GOTRY_TURN_DEADLINE_SOFT_MS')
241
+ const hardPin = resolveEnvPin('GOTRY_TURN_DEADLINE_HARD_MS')
242
+
243
+ const defaultPolicy = options.fixedPolicy ?? turnPolicyFor('sync-planning')
244
+ const routePolicy = (message: string): TurnPolicy => {
245
+ if (options.fixedPolicy) return { ...options.fixedPolicy }
246
+ const policy = turnPolicyFor(classifyTurn(message))
247
+ return { ...policy, softMs: softPin ?? policy.softMs, hardMs: hardPin ?? policy.hardMs }
248
+ }
249
+
250
+ const stateByAgent = new Map<string, AgentTurnState>()
251
+ const softExecutions = new Map<ToolExecutionToken, { agentId: string; exit: string }>()
252
+ const finalOnlyDisposers = new Map<string, () => void>()
253
+
254
+ const clearAgent = (agentId: string) => {
255
+ stateByAgent.delete(agentId)
256
+ for (const [token, owner] of softExecutions) {
257
+ if (owner.agentId === agentId) softExecutions.delete(token)
258
+ }
259
+ finalOnlyDisposers.get(agentId)?.()
260
+ finalOnlyDisposers.delete(agentId)
261
+ }
262
+
263
+ const enterFinalOnly = (agent: Agent) => {
264
+ const agentId = String(agent.id)
265
+ if (finalOnlyDisposers.has(agentId)) return
266
+ const globalTools = ctx.tools as unknown as RestrictableTools
267
+ const scopedTools = agent.ctx?.tools as unknown as RestrictableTools | undefined
268
+ if (typeof globalTools.schemas !== 'function' || typeof scopedTools?.restrict !== 'function') {
269
+ // No registry to suppress: subsequent tool calls in the same turn hit the
270
+ // same per-call denial branch and stay refused until the next turn.
271
+ return
272
+ }
273
+ const inheritedNames = globalTools.schemas()
274
+ .map(schema => schema.name)
275
+ .filter(name => name !== 'run_code')
276
+ if (inheritedNames.length === 0) return
277
+ finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }))
278
+ }
279
+
280
+ // 事件主体是 session 对象;agent 归属同一 session 时共用同一预算键。
281
+ const keyOfAgent = (agent: Agent) => {
282
+ const session = (agent as unknown as { session?: { id?: unknown } }).session
283
+ return String(session?.id ?? agent.id)
284
+ }
285
+
286
+ ctx.on('session/event', (subject, event) => {
287
+ const agentId = String(subject.id)
288
+ if (event.type === 'turn/end') {
289
+ clearAgent(agentId)
290
+ return
291
+ }
292
+ if (event.type === 'turn/start') {
293
+ finalOnlyDisposers.get(agentId)?.()
294
+ finalOnlyDisposers.delete(agentId)
295
+ if (!stateByAgent.has(agentId)) {
296
+ stateByAgent.set(agentId, {
297
+ startedAt: now(),
298
+ policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs },
299
+ })
300
+ }
301
+ return
302
+ }
303
+ if (event.type === 'user/message') {
304
+ // dsh 事件载荷在 event.data 下(benchmark-agent-conformance 同型读法)。
305
+ const message = (event as {
306
+ data?: { source?: { kind?: string }; content?: Array<{ type?: string; text?: string }> }
307
+ }).data
308
+ if (message?.source?.kind !== 'user') return
309
+ const text = (message.content ?? []).find(block => block.type === 'text')?.text ?? ''
310
+ if (!text.trim()) return
311
+ // 每个 user 消息 = 新的预算:路由分类 + 时钟重置。plugin/skill 注入
312
+ // 的 user/message(source.kind !== 'user')不参与路由。
313
+ stateByAgent.set(agentId, { startedAt: now(), policy: routePolicy(text), userMessage: text })
314
+ }
315
+ })
316
+ ctx.on('session/disposed', subject => clearAgent(String(subject.id)))
317
+
318
+ ctx.on('tools/execute', (exec, next) => {
319
+ // Programmatic/direct calls are not part of a model planner turn.
320
+ if (!exec.agent) return next()
321
+ const agentId = keyOfAgent(exec.agent)
322
+ const state = stateByAgent.get(agentId)
323
+ ?? { startedAt: now(), policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs } }
324
+ stateByAgent.set(agentId, state)
325
+
326
+ const elapsed = now() - state.startedAt
327
+ if (elapsed > state.policy.hardMs) {
328
+ // Synchronous schema suppression so same-step follow-up calls cannot
329
+ // loop on refusal results (the 2026-09-02 trajectory failure).
330
+ enterFinalOnly(exec.agent)
331
+ if (state.policy.exit === 'handoff' && state.userMessage && options.stateRoot) {
332
+ return writeTurnHandoffTicket(options.stateRoot, state.userMessage)
333
+ .then(ticket => handoffResult(ticket))
334
+ .catch(() => exhaustionResult(
335
+ `${TURN_DEADLINE_EXHAUSTED}: hard deadline ${state.policy.hardMs}ms reached (ticket persistence failed; degraded to converge)`,
336
+ ))
337
+ }
338
+ return Promise.resolve(exhaustionResult(
339
+ `${TURN_DEADLINE_EXHAUSTED}: turn exceeded hard deadline of ${state.policy.hardMs}ms (elapsed ${elapsed}ms); provide a final answer using existing results`,
340
+ ))
341
+ }
342
+ if (elapsed > state.policy.softMs) {
343
+ softExecutions.set(exec.token, { agentId, exit: state.policy.exit })
344
+ }
345
+ return next()
346
+ })
347
+
348
+ ctx.on('tools/post-execute', (exec, _result, next) => {
349
+ return next().then(decision => {
350
+ const owner = softExecutions.get(exec.token)
351
+ if (!owner) return decision
352
+ softExecutions.delete(exec.token)
353
+ const text = owner.exit === 'handoff'
354
+ ? 'Turn has crossed the soft deadline of a deep-planning turn. Wrap up context gathering now; the turn will hand off to background planning at the hard deadline.'
355
+ : 'Turn has crossed the soft deadline. Stop dispatching new tools and produce the final answer from gathered evidence.'
356
+ return {
357
+ ...decision,
358
+ additionalContexts: [
359
+ ...(decision.additionalContexts ?? []),
360
+ convergenceContext(TURN_DEADLINE_SOFT, text) as never,
361
+ ],
362
+ }
363
+ })
364
+ })
365
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Per-turn routing & time allocation (ADR-24 v2).
3
+ *
4
+ * 用户主观时间是产品路径唯一的预算:复杂度决定**出口结构**(converge /
5
+ * handoff),时间只是在所选结构内的资源分配。路由在 user/message 落地时
6
+ * 一次完成,纯函数、零 LLM、零 IO——控制面判定必须是确定性组件
7
+ * (stage1 责任铁律;ADR-9 同型先例),且不能花它要分配的资源。
8
+ *
9
+ * Tier 0(消息内禀)是 v1 的全部规则输入;Tier 1(账本读回)只在
10
+ * handoff 落单时用于工单上下文,不参与分类。`loop.ts` 的 isComplex 是
11
+ * S5 循环架构内的访谈后复核,不在产品路径上,不进本层。
12
+ */
13
+
14
+ export type TaskClass = 'quick' | 'sync-planning' | 'deep-planning'
15
+
16
+ export type TurnExit = 'converge' | 'handoff'
17
+
18
+ export interface TurnPolicy {
19
+ /** 软阈:注入收敛/转出提示,不拦截。 */
20
+ softMs: number
21
+ /** 硬阈:拒绝后续工具派发,按 exit 语义出口。 */
22
+ hardMs: number
23
+ /** 硬阈出口:converge=用已有证据作答;handoff=落工单转后台并告知用户。 */
24
+ exit: TurnExit
25
+ }
26
+
27
+ /** v1 阈值表:数据不是代码;评测/部署换表不换执行器。 */
28
+ export const TURN_POLICIES: Record<TaskClass, TurnPolicy> = {
29
+ // quick:实际上限只是安全网——quick 类历史上没有发散记录。
30
+ quick: { softMs: 120_000, hardMs: 180_000, exit: 'converge' },
31
+ 'sync-planning': { softMs: 300_000, hardMs: 600_000, exit: 'converge' },
32
+ // deep-planning:同步窗口只够"摸底 + 落单承诺",到点必须转后台。
33
+ 'deep-planning': { softMs: 120_000, hardMs: 240_000, exit: 'handoff' },
34
+ }
35
+
36
+ export const TURN_HANDOFF_ETA_LABEL = '约 1 小时'
37
+
38
+ /** 约束词表:只作弱信号(命中≥2 才可能推 deep);新增词=加一行+一测。 */
39
+ export const TURN_CONSTRAINT_LEXICON = [
40
+ '婚礼', '婚宴', '回门', '订婚',
41
+ 'IRW', 'irw',
42
+ '请假', '年假', '调休', '额度',
43
+ '签证', '同行', '伴侣', '亲子', '带娃', '父母', '团建', '出差',
44
+ '红眼', '中转', '联程', '多城',
45
+ ] as const
46
+
47
+ export interface TurnSignals {
48
+ messageLength: number
49
+ /** 消息内的绝对日期(已过 1-12/1-31 校验)。 */
50
+ absoluteDates: Array<{ year: number | null; month: number; day: number }>
51
+ /** 绝对日期≥2 时为极差天数;否则为跨度词天数;都没有则 null。 */
52
+ spanDays: number | null
53
+ /** 命中的约束词(去重,保留词表序)。 */
54
+ constraintHits: string[]
55
+ }
56
+
57
+ const CN_DIGITS: Record<string, number> = {
58
+ 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10,
59
+ }
60
+
61
+ function dayCount(year: number | null, month: number, day: number): number {
62
+ // 无年份时按平年锚定:跨度计算只需要差值,月份跨年场景 v1 不覆盖。
63
+ const y = year ?? 1970
64
+ return Date.UTC(y, month - 1, day) / 86_400_000
65
+ }
66
+
67
+ /** 跨度词 → 估算天数;按声明强度取第一个命中,v1 不做组合。 */
68
+ function spanWordDays(message: string): number | null {
69
+ if (/十几天/.test(message)) return 12
70
+ if (/半个月/.test(message)) return 15
71
+ if (/一个月/.test(message)) return 30
72
+ const week = message.match(/[一两]个?(?:星期|周)/)
73
+ if (week) return week[0].startsWith('两') ? 14 : 7
74
+ const dayMatch = message.match(/([0-9一二两三四五六七八九十]+)\s*天/)
75
+ if (dayMatch) {
76
+ const raw = dayMatch[1]
77
+ if (/^[0-9]+$/.test(raw)) return Math.min(Number.parseInt(raw, 10), 60)
78
+ if (CN_DIGITS[raw] !== undefined) return Math.min(CN_DIGITS[raw], 60)
79
+ }
80
+ return null
81
+ }
82
+
83
+ export function extractTurnSignals(message: string): TurnSignals {
84
+ const absoluteDates: TurnSignals['absoluteDates'] = []
85
+
86
+ // 完整日期先摘除,避免 2026-10-03 再被月/日模式重复计数。
87
+ let rest = message.replace(
88
+ /(?<!\d)(\d{4})[年\-.\/](\d{1,2})[月\-.\/](\d{1,2})日?(?!\d)/g,
89
+ (_all, y: string, m: string, d: string) => {
90
+ const month = Number.parseInt(m, 10)
91
+ const day = Number.parseInt(d, 10)
92
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
93
+ absoluteDates.push({ year: Number.parseInt(y, 10), month, day })
94
+ }
95
+ return ' '
96
+ },
97
+ )
98
+ rest = rest.replace(
99
+ /(?<!\d)(\d{1,2})月(\d{1,2})日?(?!\d)/g,
100
+ (_all, m: string, d: string) => {
101
+ const month = Number.parseInt(m, 10)
102
+ const day = Number.parseInt(d, 10)
103
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
104
+ absoluteDates.push({ year: null, month, day })
105
+ }
106
+ return ' '
107
+ },
108
+ )
109
+ rest = rest.replace(
110
+ /(?<!\d)(\d{1,2})[.\/](\d{1,2})(?!\d)/g,
111
+ (_all, m: string, d: string) => {
112
+ const month = Number.parseInt(m, 10)
113
+ const day = Number.parseInt(d, 10)
114
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
115
+ absoluteDates.push({ year: null, month, day })
116
+ }
117
+ return ' '
118
+ },
119
+ )
120
+
121
+ const constraintHits = TURN_CONSTRAINT_LEXICON.filter(word => message.includes(word))
122
+
123
+ let spanDays: number | null = null
124
+ if (absoluteDates.length >= 2) {
125
+ const counts = absoluteDates.map(d => dayCount(d.year, d.month, d.day))
126
+ spanDays = Math.max(...counts) - Math.min(...counts)
127
+ } else {
128
+ spanDays = spanWordDays(message)
129
+ }
130
+
131
+ return { messageLength: message.length, absoluteDates, spanDays, constraintHits }
132
+ }
133
+
134
+ /**
135
+ * 分类规则(有序,可表测):长跨度或多约束 → deep(转后台);无日期无约束
136
+ * 且短 → quick;其余落默认中间档 sync-planning。误分有界:三态出口保证
137
+ * 最坏结局是"本可当面答的被转了后台"(下一轮可救),而非流死掉。
138
+ */
139
+ export function classifyTurn(message: string): TaskClass {
140
+ const s = extractTurnSignals(message)
141
+ if (s.spanDays !== null && s.spanDays >= 7) return 'deep-planning'
142
+ if (s.absoluteDates.length >= 2 && s.constraintHits.length >= 1) return 'deep-planning'
143
+ if (s.constraintHits.length >= 2 && (s.absoluteDates.length >= 1 || s.spanDays !== null)) {
144
+ return 'deep-planning'
145
+ }
146
+ if (s.absoluteDates.length === 0 && s.constraintHits.length === 0 && s.spanDays === null && s.messageLength <= 60) {
147
+ return 'quick'
148
+ }
149
+ return 'sync-planning'
150
+ }
151
+
152
+ export function turnPolicyFor(taskClass: TaskClass): TurnPolicy {
153
+ return { ...TURN_POLICIES[taskClass] }
154
+ }
@@ -10,16 +10,19 @@ export interface WishPoolEntry {
10
10
  wish_id?: string | unknown
11
11
  name?: string | unknown
12
12
  reason?: string | unknown
13
- conditions?: { days?: number; budget_cny?: number; best_months?: number[] } | unknown
13
+ conditions?: { days?: number; budget_cny?: number; best_months?: number[]; channels?: string[] } | unknown
14
14
  muted?: boolean | unknown
15
15
  added_at?: string | unknown
16
16
  }
17
17
 
18
- /** 召回窗口上下文:用户当前可支配的天数/预算 + 目标月份 */
18
+ /** 召回窗口上下文:用户当前可支配的天数/预算 + 目标月份;
19
+ * channelDown(外部事件接缝第 2 段,seam 设计 §3.3/§6②)=当前处于 down 的通道名
20
+ * 集合(来自 channel-health.jsonl 持久面;缺省=不启用通道否证,旧调用方零影响)。 */
19
21
  export interface WishMatchContext {
20
22
  days?: number
21
23
  budgetCny?: number
22
24
  month?: number
25
+ channelDown?: ReadonlySet<string>
23
26
  }
24
27
 
25
28
  export interface WishMatch {
@@ -30,9 +33,19 @@ export interface WishMatch {
30
33
  hits: string[]
31
34
  }
32
35
 
33
- /** 条件评分:days/budget 为「窗口 ≥ 条件」,month 为「命中 best_months」;条件缺失的项不计分 */
36
+ /**
37
+ * 条件评分:days/budget 为「窗口 ≥ 条件」,month 为「命中 best_months」;条件缺失的项不计分。
38
+ * 通道否证(接缝第 2 段):conditions.channels 命名依赖通道,任一处于 down 且
39
+ * ctx.channelDown 已提供 → 否证成行条件,返回 null(当下推这个愿望=体验差);
40
+ * 通道全健康或健康面缺席(未传 channelDown)不加分不否——「佐证」渲染面留后续切片。
41
+ */
34
42
  export function scoreWishMatch(entry: WishPoolEntry, ctx: WishMatchContext): WishMatch | null {
35
- const c = (entry.conditions ?? {}) as { days?: number; budget_cny?: number; best_months?: number[] }
43
+ const c = (entry.conditions ?? {}) as { days?: number; budget_cny?: number; best_months?: number[]; channels?: string[] }
44
+ if (ctx.channelDown && Array.isArray(c.channels)) {
45
+ for (const ch of c.channels) {
46
+ if (typeof ch === 'string' && ctx.channelDown.has(ch)) return null
47
+ }
48
+ }
36
49
  const hits: string[] = []
37
50
  if (typeof ctx.days === 'number' && typeof c.days === 'number' && ctx.days >= c.days) hits.push(`days≥${c.days}`)
38
51
  if (typeof ctx.budgetCny === 'number' && typeof c.budget_cny === 'number' && ctx.budgetCny >= c.budget_cny) hits.push(`budget≥${c.budget_cny}`)