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

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 (137) hide show
  1. package/README.md +185 -148
  2. package/README.zh-CN.md +195 -154
  3. package/bin/gotry-bootstrap.js +246 -26
  4. package/bin/gotry-inner.js +68 -38
  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 +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +115 -44
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -1,247 +0,0 @@
1
- /** Request adapter for the unified Booking Copilot HTTP/SSE server. */
2
-
3
- import type { IncomingMessage, ServerResponse } from 'node:http'
4
- import {
5
- BOOKING_SURFACE_SCHEMA_V2_SHA256,
6
- BOOKING_SURFACE_SCHEMA_VERSION_V2,
7
- BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS,
8
- type BookingCopilotV2IngressMode,
9
- BOOKING_READ_ACTION_KINDS_V2,
10
- BOOKING_SURFACE_ALLOWED_ACTIONS_V2,
11
- type BookingCopilotTurnV2,
12
- type BookingIngressBindingV2,
13
- type BookingIngressPrincipalV2,
14
- type IngressTurnV2,
15
- type UserTurnV2,
16
- type BookingSurfaceEventV2,
17
- type BookingWorkspaceSnapshotV2,
18
- } from './contracts-v2.ts'
19
- import {
20
- BookingCopilotTaskRuntimeV2,
21
- type BookingPlannerSessionFactoryV2,
22
- type BookingCopilotTaskStateV2,
23
- type BookingIngressRequestBindingInputV2,
24
- } from './runtime-v2.ts'
25
- import { validateBookingSurfaceV2 } from './validation-v2.ts'
26
- import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
27
-
28
- export interface BookingCopilotV2Adapter {
29
- runtime: BookingCopilotTaskRuntimeV2
30
- plannerFactory: BookingPlannerSessionFactoryV2
31
- /** Optional BFF-owned identity binding. GoTry never accepts browser identity. */
32
- ingressBinding?: BookingIngressBindingV2
33
- /** Principal authenticated by the HotelByte BFF, never by browser input. */
34
- principal?: BookingIngressPrincipalV2
35
- /** Explicit process composition mode; omitted means infer from a complete pair. */
36
- ingressMode?: BookingCopilotV2IngressMode
37
- }
38
-
39
- const sessionsByAdapter = new WeakMap<BookingCopilotV2Adapter, Map<string, ReturnType<BookingPlannerSessionFactoryV2>>>()
40
- const decisionFlightsByAdapter = new WeakMap<BookingCopilotV2Adapter, Map<string, Promise<BookingSurfaceEventV2[]>>>()
41
- function sessionsFor(adapter: BookingCopilotV2Adapter): Map<string, ReturnType<BookingPlannerSessionFactoryV2>> {
42
- let sessions = sessionsByAdapter.get(adapter)
43
- if (!sessions) { sessions = new Map(); sessionsByAdapter.set(adapter, sessions) }
44
- return sessions
45
- }
46
- function decisionFlightsFor(adapter: BookingCopilotV2Adapter): Map<string, Promise<BookingSurfaceEventV2[]>> {
47
- let flights = decisionFlightsByAdapter.get(adapter)
48
- if (!flights) { flights = new Map(); decisionFlightsByAdapter.set(adapter, flights) }
49
- return flights
50
- }
51
- async function runDecisionSingleFlight(adapter: BookingCopilotV2Adapter, key: string, work: () => Promise<BookingSurfaceEventV2[]>): Promise<BookingSurfaceEventV2[]> {
52
- const flights = decisionFlightsFor(adapter)
53
- const prior = flights.get(key)
54
- if (prior) return prior
55
- const current = work()
56
- flights.set(key, current)
57
- try { return await current } finally { if (flights.get(key) === current) flights.delete(key) }
58
- }
59
-
60
- function send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)) }
61
- function readBody(req: IncomingMessage, max: number): Promise<string> {
62
- return new Promise((resolve, reject) => {
63
- const chunks: Buffer[] = []; let size = 0; let tooLarge = false
64
- req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > max) tooLarge = true; else chunks.push(chunk) })
65
- req.on('end', () => tooLarge ? reject(new Error('payload_too_large')) : resolve(Buffer.concat(chunks).toString('utf8')))
66
- req.on('error', reject)
67
- })
68
- }
69
- function write(res: ServerResponse, event: BookingSurfaceEventV2): void { res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`) }
70
- export { normalizeBookingErrorCode as bookingPlannerErrorCode, safeBookingErrorMessage as bookingPlannerErrorMessage } from './error-codes.ts'
71
-
72
- function sameCapabilities(a: readonly string[], b: readonly string[]): boolean {
73
- return a.length === b.length && [...a].sort().every((kind, index) => kind === [...b].sort()[index])
74
- }
75
- function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
76
- return Object.keys(value).length === allowed.length && Object.keys(value).every((key) => allowed.includes(key))
77
- }
78
-
79
- const SURFACE_ACTION_MATRIX: Record<BookingWorkspaceSnapshotV2['surface'], readonly string[]> = BOOKING_SURFACE_ALLOWED_ACTIONS_V2
80
-
81
- function validSurfaceActions(surface: unknown, allowed: unknown): allowed is BookingWorkspaceSnapshotV2['capabilities']['allowedActions'] {
82
- if (typeof surface !== 'string' || !Object.prototype.hasOwnProperty.call(SURFACE_ACTION_MATRIX, surface)) return false
83
- if (!Array.isArray(allowed) || allowed.length < 1 || new Set(allowed).size !== allowed.length) return false
84
- const matrix = SURFACE_ACTION_MATRIX[surface as BookingWorkspaceSnapshotV2['surface']]
85
- return allowed.every((action) => typeof action === 'string'
86
- && matrix.includes(action)
87
- && (BOOKING_READ_ACTION_KINDS_V2 as readonly string[]).includes(action))
88
- }
89
-
90
- function validBoundWorkspaceAuthority(workspace: BookingWorkspaceSnapshotV2): boolean {
91
- return workspace.capabilities.surface === workspace.surface
92
- && validSurfaceActions(workspace.surface, workspace.capabilities.allowedActions)
93
- }
94
-
95
- function validIngressBinding(binding: unknown, ingress: IngressTurnV2): binding is {
96
- taskId: string
97
- turnId: string
98
- contextRef: string
99
- surface: BookingWorkspaceSnapshotV2['surface']
100
- allowedActions: BookingWorkspaceSnapshotV2['capabilities']['allowedActions']
101
- } {
102
- if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return false
103
- const candidate = binding as Record<string, unknown>
104
- if (!exactKeys(candidate, ['taskId', 'turnId', 'contextRef', 'surface', 'allowedActions'])) return false
105
- if (typeof candidate.taskId !== 'string' || typeof candidate.turnId !== 'string' || typeof candidate.contextRef !== 'string') return false
106
- if (typeof candidate.surface !== 'string' || candidate.surface !== ingress.surfaceHint || !Object.prototype.hasOwnProperty.call(SURFACE_ACTION_MATRIX, candidate.surface)) return false
107
- return validSurfaceActions(candidate.surface, candidate.allowedActions)
108
- }
109
-
110
- function fallbackErrorEvent(task: BookingCopilotTaskStateV2, code: string): BookingSurfaceEventV2 {
111
- return {
112
- schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION_V2,
113
- eventId: `error-${task.taskId}-${task.lastSequence + 1}`,
114
- taskId: task.taskId,
115
- contextRef: task.contextRef,
116
- sequence: task.lastSequence + 1,
117
- emittedAt: new Date().toISOString(),
118
- kind: 'error',
119
- error: { code: normalizeBookingErrorCode(code), message: safeBookingErrorMessage(normalizeBookingErrorCode(code)), retryable: false },
120
- }
121
- }
122
-
123
- function writeTypedError(res: ServerResponse, adapter: BookingCopilotV2Adapter, task: BookingCopilotTaskStateV2, error: unknown, requestKey?: string, includeSubmitted = false): void {
124
- const code = normalizeBookingErrorCode(error)
125
- try {
126
- const decision = { kind: 'error' as const, error: { code, message: safeBookingErrorMessage(code), retryable: false } }
127
- if (requestKey) {
128
- const events = adapter.runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted)
129
- for (const event of events) write(res, event)
130
- return
131
- }
132
- write(res, adapter.runtime.emitEvent(task.taskId, decision))
133
- } catch {
134
- // The stream is already committed. Emit a schema-valid, non-durable
135
- // typed envelope instead of silently returning an empty HTTP 200 stream.
136
- write(res, fallbackErrorEvent(task, code))
137
- }
138
- }
139
-
140
- /** Handles one already-authenticated v2 request inside server.ts's sole listener. */
141
- export async function handleBookingCopilotV2Request(req: IncomingMessage, res: ServerResponse, adapter: BookingCopilotV2Adapter, maxBodyBytes: number): Promise<void> {
142
- let turn: Exclude<BookingCopilotTurnV2, IngressTurnV2>
143
- let browserRequestKey: string | undefined
144
- let requestBinding: BookingIngressRequestBindingInputV2 | undefined
145
- try {
146
- const parsed = JSON.parse(await readBody(req, maxBodyBytes)) as unknown
147
- const valid = validateBookingSurfaceV2(parsed)
148
- if (!valid.ok) { send(res, 400, { error: { code: 'invalid_booking_surface_turn', details: valid.errors } }); return }
149
- if ((parsed as BookingCopilotTurnV2).kind === 'user.turn.ingress') {
150
- const ingress = parsed as IngressTurnV2
151
- browserRequestKey = ingress.requestKey
152
- if (adapter.ingressMode !== 'bff-ingress-binding' || !adapter.ingressBinding || !adapter.principal) {
153
- send(res, 503, { error: { code: 'trusted_ingress_binding_required', mode: adapter.ingressMode ?? 'bff-bound-turn-only', acceptedTurnKinds: [...BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS] } })
154
- return
155
- }
156
- const binding = await adapter.ingressBinding.bind(ingress, adapter.principal)
157
- if (!validIngressBinding(binding, ingress)) { send(res, 502, { error: { code: 'invalid_ingress_binding' } }); return }
158
- const boundWorkspace = {
159
- ...ingress.workspace,
160
- contextRef: binding.contextRef,
161
- surface: binding.surface,
162
- capabilities: { surface: binding.surface, allowedActions: [...binding.allowedActions] },
163
- } as BookingWorkspaceSnapshotV2
164
- turn = { schemaVersion: 'booking.surface.v2', kind: 'user.turn', taskId: binding.taskId, turnId: binding.turnId, workspace: boundWorkspace, request: ingress.request }
165
- requestBinding = { requestKey: ingress.requestKey, principal: adapter.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) }
166
- const boundValid = validateBookingSurfaceV2(turn)
167
- if (!boundValid.ok) { send(res, 502, { error: { code: 'invalid_ingress_binding', details: boundValid.errors } }); return }
168
- } else {
169
- turn = parsed as Exclude<BookingCopilotTurnV2, IngressTurnV2>
170
- if (!validBoundWorkspaceAuthority(turn.workspace)) {
171
- send(res, 403, { error: { code: 'invalid_bound_turn_authority' } })
172
- return
173
- }
174
- }
175
- } catch (error) { const code = normalizeBookingErrorCode(error); send(res, code === 'PAYLOAD_TOO_LARGE' ? 413 : 400, { error: { code } }); return }
176
-
177
- let task: BookingCopilotTaskStateV2; let fresh = false; let replayKey: string | undefined; let replayEvents: BookingSurfaceEventV2[] | null = null; let activeDecisionKey: string | undefined
178
- try {
179
- fresh = turn.kind === 'action.receipt.continuation' ? false : !turn.taskId || adapter.runtime.resumeTask(turn.taskId) === null
180
- if (turn.kind === 'action.receipt.continuation') {
181
- replayKey = `receipt:${turn.receipt.actionId}:${adapter.runtime.receiptDigest(turn.receipt)}`
182
- task = adapter.runtime.continueWithReceipt(turn)
183
- if (task.awaitingApproval) {
184
- replayKey = adapter.runtime.approvalPresentationRequestKey(turn.taskId)
185
- }
186
- replayEvents = adapter.runtime.readDecisionBatch(turn.taskId, replayKey)
187
- if (!replayEvents && task.phase === 'terminal') replayEvents = adapter.runtime.terminalDecisionBatch(turn.taskId, replayKey)
188
- } else {
189
- const existing = turn.taskId ? adapter.runtime.resumeTask(turn.taskId) : null
190
- if (requestBinding && turn.kind === 'user.turn') adapter.runtime.assertRequestBinding(browserRequestKey!, turn, requestBinding)
191
- const requestReplayKey = existing && browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : undefined
192
- const directReplayKey = existing && turn.kind === 'user.turn' && turn.turnId ? `turn:${existing.taskId}:${turn.turnId}` : undefined
193
- const stableReplayKey = requestReplayKey ?? directReplayKey
194
- const stableReplay = stableReplayKey ? adapter.runtime.readDecisionBatch(existing!.taskId, stableReplayKey) : null
195
- if (stableReplay) {
196
- if (existing && turn.kind === 'user.turn' && directReplayKey) adapter.runtime.assertTurnBinding(existing.taskId, turn)
197
- task = existing!
198
- replayKey = stableReplayKey
199
- replayEvents = stableReplay
200
- } else if (existing?.phase === 'terminal' || existing?.phase === 'error') {
201
- throw new Error('task_terminal')
202
- } else if (existing?.phase === 'waiting_receipt') {
203
- const workspace = turn.workspace as BookingWorkspaceSnapshotV2
204
- if (workspace.contextRef !== existing.contextRef || workspace.surface !== existing.surface || workspace.revision !== existing.revision || workspace.capabilities.surface !== existing.surface || !sameCapabilities(workspace.capabilities.allowedActions, existing.allowedActions)) throw new Error('task_conflict:workspace_mismatch')
205
- // A waiting task can only be replayed with the caller's stable turn
206
- // identity. Without it, an identical sentence is not distinguishable
207
- // from a new user turn and must not receive an old operation batch.
208
- if (!turn.turnId || existing.lastTurnId !== turn.turnId) throw new Error('receipt_required')
209
- replayKey = browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : `turn:${existing.taskId}:${existing.lastTurnId ?? existing.userTurnCount}`
210
- replayEvents = adapter.runtime.readDecisionBatch(existing.taskId, replayKey)
211
- if (!replayEvents) throw new Error('receipt_required')
212
- task = existing
213
- } else {
214
- task = adapter.runtime.startTask(turn, requestBinding)
215
- if (turn.kind === 'user.turn' && turn.taskId) {
216
- replayKey = browserRequestKey ? `ingress:${task.taskId}:${browserRequestKey}` : `turn:${task.taskId}:${turn.turnId ?? task.userTurnCount}`
217
- replayEvents = adapter.runtime.readDecisionBatch(task.taskId, replayKey)
218
- }
219
- }
220
- }
221
- } catch (error) { send(res, 409, { error: { code: normalizeBookingErrorCode(error) } }); return }
222
-
223
- res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', 'x-content-type-options': 'nosniff', 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION_V2, 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_V2_SHA256 })
224
- try {
225
- if (replayEvents) {
226
- for (const event of replayEvents) write(res, event)
227
- } else {
228
- const requestKey = replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`
229
- activeDecisionKey = requestKey
230
- const flightKey = JSON.stringify([task.taskId, requestKey])
231
- const decisionEvents = await runDecisionSingleFlight(adapter, flightKey, async () => {
232
- if (turn.kind === 'action.receipt.continuation' && task.awaitingApproval) {
233
- return adapter.runtime.applyDecisionBatch(task.taskId, requestKey, [adapter.runtime.approvalQuestion(task.taskId)], false)
234
- }
235
- const sessions = sessionsFor(adapter)
236
- const session = sessions.get(task.taskId) ?? adapter.plannerFactory(task)
237
- sessions.set(task.taskId, session)
238
- const decisions = await session.next({ turn, task: adapter.runtime.resumeTask(task.taskId) ?? task })
239
- const plannerDecisions = decisions.length ? decisions : [{ kind: 'error' as const, error: { code: 'PLANNER_NO_DECISION', message: 'Planner returned no typed decision.', retryable: false } }]
240
- return adapter.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh)
241
- })
242
- for (const event of decisionEvents) write(res, event)
243
- }
244
- } catch (error) {
245
- writeTypedError(res, adapter, task, error, activeDecisionKey, fresh)
246
- } finally { res.end() }
247
- }
@@ -1,205 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import { Ajv2020, type ValidateFunction } from 'ajv/dist/2020.js'
3
- import { BOOKING_READ_ACTION_KINDS_V2, BOOKING_V2_BLOCKER_CODES, BOOKING_V2_GAP_CODES } from './contracts-v2.ts'
4
-
5
- const schema = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.v2.schema.json', import.meta.url), 'utf8')) as Record<string, unknown>
6
- const v1 = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.v1.schema.json', import.meta.url), 'utf8')) as Record<string, unknown>
7
- const ajv = new Ajv2020({ allErrors: true, strict: true })
8
- ajv.addSchema(v1)
9
- ajv.addSchema(schema)
10
- const validate: ValidateFunction = ajv.compile(schema)
11
- const validateActionCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Action` })
12
- const validateBlockerCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Blocker` })
13
- const validateApprovalCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Approval` })
14
- const validateEventCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Event` })
15
- const kinds = new Set<string>(BOOKING_READ_ACTION_KINDS_V2)
16
- const codes = new Set<string>(BOOKING_V2_BLOCKER_CODES)
17
- const gapCodes = new Set<string>(BOOKING_V2_GAP_CODES)
18
- const safeOpaque = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)
19
- const safeRequestKey = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{7,127}$/.test(value) && !secret.test(value)
20
- const secret = /(?:Bearer\s+\S|\bsk-(?!ill(?:s)?\b)[A-Za-z0-9_-]{12,}|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i
21
- // `.example` is reserved for documentation, so these identifiers cannot be a
22
- // real mailbox. Do not allowlist lookalikes on routable domains.
23
- const technicalEmail = /^(?:schema|version)@[a-z0-9-]+\.example$/i
24
- const currencyAmount = /\b(?:[A-Z]{3}\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*[A-Z]{3})\b/
25
- const rfc3339DateTime = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/
26
-
27
- export function isBookingDateTimeV2(value: unknown): value is string {
28
- if (typeof value !== 'string') return false
29
- const match = rfc3339DateTime.exec(value)
30
- if (!match) return false
31
- const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match
32
- const year = Number(yearText)
33
- const month = Number(monthText)
34
- const day = Number(dayText)
35
- const hour = Number(hourText)
36
- const minute = Number(minuteText)
37
- const second = Number(secondText)
38
- const offsetHour = match[9] === undefined ? 0 : Number(match[9])
39
- const offsetMinute = match[10] === undefined ? 0 : Number(match[10])
40
- if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return false
41
- const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
42
- if (day < 1 || day > daysInMonth) return false
43
- return !Number.isNaN(Date.parse(value))
44
- }
45
-
46
- function normalizedWords(value: string): string {
47
- return value.normalize('NFKC').replace(/[^\p{L}\p{N}.]+/gu, ' ').trim()
48
- }
49
-
50
- function hasSupplierCost(value: string): boolean {
51
- const normalized = normalizedWords(value)
52
- const lower = normalized.toLowerCase()
53
- return (
54
- /\b(?:supplier|vendor|internal)\b/.test(lower) &&
55
- /\b(?:cost|rate|price)\b/.test(lower) &&
56
- currencyAmount.test(normalized.toUpperCase())
57
- )
58
- }
59
-
60
- function hasInternationalPhone(value: string): boolean {
61
- const normalized = value.normalize('NFKC')
62
- for (const plusIndex of normalized.split('').flatMap((char, index) => char === '+' ? [index] : [])) {
63
- let digits = ''
64
- for (const char of normalized.slice(plusIndex + 1, plusIndex + 48)) {
65
- if (/\d/u.test(char)) digits += char
66
- else if (/\p{L}|@/u.test(char)) break
67
- if (digits.length > 15) break
68
- }
69
- if (/^[1-9]\d{7,14}$/.test(digits)) return true
70
- }
71
- return false
72
- }
73
-
74
- function hasPaymentCard(value: string): boolean {
75
- const normalized = value.normalize('NFKC')
76
- const candidates = normalized.match(/(?:\d[ -]?){12,18}\d/g) ?? []
77
- const explicitlyLabeled = /\b(?:card|payment|visa|mastercard)\b/i.test(normalized)
78
- return candidates.some((candidate) => (explicitlyLabeled || /[ -]/.test(candidate)) && luhn(candidate))
79
- }
80
-
81
- function hasSensitiveText(value: string, opts: { allowOpaqueNumericId?: boolean } = {}): boolean {
82
- const normalized = value.normalize('NFKC')
83
- if (normalized.includes('@') && !technicalEmail.test(normalized.trim())) return true
84
- return secret.test(normalized) || hasInternationalPhone(normalized) || hasSupplierCost(normalized) || (!opts.allowOpaqueNumericId && hasPaymentCard(normalized))
85
- }
86
- function luhn(value: string): boolean { const digits = value.replaceAll(/\D/g, ''); if (digits.length < 13 || digits.length > 19) return false; let sum = 0; for (let i = digits.length - 1, parity = 0; i >= 0; i--, parity++) { let n = Number(digits[i]); if (parity % 2 === 1) { n *= 2; if (n > 9) n -= 9 } sum += n } return sum % 10 === 0 }
87
- const opaqueFieldNames = new Set([
88
- 'eventId', 'taskId', 'contextRef', 'actionId', 'hotelRef', 'offerRef', 'offerVersionRef', 'checkedOfferVersionRef', 'currentOfferVersionRef', 'verifiedOfferRef',
89
- 'orderRef', 'searchSessionRef', 'handoffRef', 'questionId', 'blockerId', 'approvalId',
90
- 'sourceActionId', 'targetActionId', 'sourceTurnId', 'turnId', 'requestKey', 'taskHandle',
91
- 'nonce', 'deliveryNonce', 'optionDigest', 'sourceReceiptDigest', 'valueDigest',
92
- ])
93
- function isOpaqueFieldPath(path: string): boolean {
94
- const clean = path.replace(/\[\]$/g, '')
95
- const field = clean.split('.').at(-1)
96
- return field ? opaqueFieldNames.has(field) || field.endsWith('Ref') || field.endsWith('Refs') : false
97
- }
98
- const scan = (v: unknown, path = '$'): boolean => typeof v === 'string'
99
- ? hasSensitiveText(v, { allowOpaqueNumericId: isOpaqueFieldPath(path) })
100
- : Array.isArray(v)
101
- ? v.some((child) => scan(child, `${path}[]`))
102
- : !!v && typeof v === 'object'
103
- ? Object.entries(v).some(([k,x]) => /cost|price/i.test(k) ? /supplier|internal|wholesale/i.test(String(x)) : scan(x, `${path}.${k}`))
104
- : false
105
- export function isSafeBookingRequestKeyV2(value: unknown): value is string { return safeRequestKey(value) }
106
- export type BookingSurfaceV2ValidationResult = { ok: true } | { ok: false; errors: string[] }
107
- function errors(v: ValidateFunction): string[] { return (v.errors ?? []).map(e => `${e.instancePath || '$'}: ${e.message ?? e.keyword}`) }
108
- function workspaceErrors(workspace: Record<string, any> | undefined, path: string): string[] {
109
- const e: string[] = []
110
- if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) return e
111
- const loadedOffers = Array.isArray(workspace.loadedOffers) ? workspace.loadedOffers : []
112
- const offerRefs = loadedOffers.map((offer: any) => offer?.offerRef).filter((ref: unknown): ref is string => typeof ref === 'string')
113
- const offerVersionRefs = loadedOffers.map((offer: any) => offer?.offerVersionRef).filter((ref: unknown): ref is string => typeof ref === 'string')
114
- if (new Set(offerRefs).size !== offerRefs.length) e.push(`${path}.loadedOffers: duplicate offerRef`)
115
- if (new Set(offerVersionRefs).size !== offerVersionRefs.length) e.push(`${path}.loadedOffers: duplicate offerVersionRef`)
116
- const verified = workspace.verifiedOffer
117
- if (verified !== undefined) {
118
- if (!loadedOffers.some((offer: any) => offer?.offerRef === verified.offerRef && offer?.offerVersionRef === verified.offerVersionRef)) e.push(`${path}.verifiedOffer: loaded offer version required`)
119
- if (!safeOpaque(verified.verifiedOfferRef) || !isBookingDateTimeV2(verified.expiresAt)) e.push(`${path}.verifiedOffer: safe ref and parseable expiry required`)
120
- }
121
- return e
122
- }
123
- function cross(value: unknown): string[] {
124
- const e: string[] = []
125
- if (scan(value)) e.push('$: unsafe PII/payment/supplier-cost/credential text')
126
- if (!value || typeof value !== 'object' || Array.isArray(value)) return e
127
- const x = value as Record<string, any>
128
- const semantic = x.kind === 'action.receipt.continuation' ? x.receipt : x
129
- if (x.workspace && x.kind !== 'user.turn.ingress') e.push(...workspaceErrors(x.workspace, '$.workspace'))
130
- if (x.schemaVersion === 'booking.surface.v2' && Array.isArray(x.loadedOffers)) e.push(...workspaceErrors(x, '$'))
131
- if (x.kind === 'user.turn.ingress') {
132
- if (!safeRequestKey(x.requestKey)) e.push('$.requestKey: safe opaque request key required')
133
- const workspace = x.workspace as Record<string, any> | undefined
134
- e.push(...workspaceErrors(workspace, '$.workspace'))
135
- const visibleHotels = Array.isArray(workspace?.visibleHotels) ? workspace.visibleHotels : []
136
- const loadedOffers = Array.isArray(workspace?.loadedOffers) ? workspace.loadedOffers : []
137
- if (workspace?.focusedHotelRef !== undefined && !visibleHotels.some((hotel: any) => hotel?.hotelRef === workspace.focusedHotelRef)) e.push('$.workspace.focusedHotelRef: visible hotel required')
138
- const selectedOffer = workspace?.selectedOfferRef === undefined ? undefined : loadedOffers.find((offer: any) => offer?.offerRef === workspace.selectedOfferRef)
139
- if (workspace?.selectedOfferRef !== undefined && !selectedOffer) e.push('$.workspace.selectedOfferRef: loaded offer required')
140
- if (selectedOffer && !visibleHotels.some((hotel: any) => hotel?.hotelRef === selectedOffer.hotelRef)) e.push('$.workspace.selectedOfferRef: offer hotel must be visible')
141
- }
142
- if (semantic !== x && semantic?.resultContract?.hardCriteriaMet === true && semantic.resultContract.blockers?.length) e.push('$.receipt.resultContract: hardCriteriaMet cannot coexist with blockers')
143
- if (kinds.has(x.kind)) {
144
- const allowed: Record<string, string[]> = {
145
- 'search.patch': ['patch'], 'search.run': [], 'results.view.patch': ['patch'],
146
- 'hotel.focus': ['hotelRef'], 'hotel.select': ['hotelRef'],
147
- 'offers.query': ['hotelRefs', 'criteria'], 'offers.view.patch': ['hotelRef', 'criteria'],
148
- 'offers.compare': ['offerRefs', 'requestedCount'], 'offer.select': ['offerRef', 'offerVersionRef'],
149
- 'offer.check': ['offerRef', 'offerVersionRef'], 'checkout.prepare': ['offerRef', 'offerVersionRef', 'verifiedOfferRef'], 'order.observe': ['orderRef'],
150
- }
151
- const input = x.input
152
- if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(k => !allowed[x.kind].includes(k))) e.push('$.input: closed kind-specific input required')
153
- }
154
- if (x.kind === 'action.receipt' && x.resultContract?.hardCriteriaMet === true && x.resultContract.blockers?.length) e.push('$.resultContract: hardCriteriaMet cannot coexist with blockers')
155
- if (x.kind === 'user.turn' && Array.isArray(x.request?.approval)) e.push('$.request.approval: at most one approval')
156
- if (x.kind === 'action.receipt' || x.kind === 'action.receipt.continuation') {
157
- const r = x.kind === 'action.receipt.continuation' ? x.receipt : x
158
- if (r?.resultContract?.gapCodes?.some((code: unknown) => !gapCodes.has(String(code)))) e.push('$.resultContract.gapCodes: unregistered gap code')
159
- const observation = r?.observation
160
- if (observation?.kind === 'offer.availability') {
161
- if (r.status === 'applied' && (observation.available !== true || !observation.verifiedOfferRef || observation.currentOfferVersionRef !== observation.checkedOfferVersionRef || observation.changedFactRefs?.length || observation.gapCodes?.length)) e.push('$.observation: applied availability requires unchanged checked/current version, verified ref, and no gaps')
162
- if (r.status === 'applied' && (r.resultContract?.outcome !== 'complete' || r.resultContract?.hardCriteriaMet !== true || r.resultContract?.gapCodes?.length || r.resultContract?.blockers?.length)) e.push('$.resultContract: applied availability requires complete hard-criteria result without gaps or blockers')
163
- if (r.status === 'changed' && (observation.available !== true || !observation.currentOfferVersionRef || observation.currentOfferVersionRef === observation.checkedOfferVersionRef || !observation.changedFactRefs?.length || observation.verifiedOfferRef)) e.push('$.observation: changed availability requires available replacement current version without verified ref')
164
- if ((r.status === 'unavailable' || r.status === 'no_match') && (observation.available !== false || observation.currentOfferVersionRef || observation.verifiedOfferRef)) e.push('$.observation: negative availability must not expose current or verified version')
165
- if (r.status !== 'applied' && observation.verifiedOfferRef) e.push('$.observation: only applied availability may expose verified ref')
166
- }
167
- }
168
- const blocker = x.blocker ?? x.question?.blocker
169
- if (blocker && !/^(searchDraft|results|offers|checkout)(\.[A-Za-z][A-Za-z0-9_]*){0,3}$/.test(blocker.criterionPath ?? '')) e.push('$.blocker.criterionPath: closed criterion path required')
170
- if (blocker?.evidence?.factRefs && new Set(blocker.evidence.factRefs).size !== blocker.evidence.factRefs.length) e.push('$.blocker.evidence.factRefs: unique refs required')
171
- if (x.kind === 'question' && blocker && Array.isArray(x.question?.approvalOptions)) {
172
- for (const [i, option] of x.question.approvalOptions.entries()) {
173
- const result = validateApprovalAgainstBlocker(option?.approval, blocker)
174
- if (!result.ok) e.push(...result.errors.map(error => `$.question.approvalOptions[${i}].${error.slice(2)}`))
175
- }
176
- }
177
- if (kinds.has(String(x.kind)) && x.relaxationApprovalRef) {
178
- const ref = x.relaxationApprovalRef
179
- if (ref.contextRef !== x.contextRef) e.push('$.relaxationApprovalRef.contextRef: must match action')
180
- if (ref.sourceRevision !== x.expectedRevision) e.push('$.relaxationApprovalRef.sourceRevision: must equal action revision')
181
- if (ref.targetActionKind !== x.kind) e.push('$.relaxationApprovalRef.targetActionKind: must match action kind')
182
- if (ref.targetActionId !== x.actionId) e.push('$.relaxationApprovalRef.targetActionId: must match action id')
183
- // Expiry and one-time consumption are Phase B runtime concerns; Phase A validates only structure/binding.
184
- if (!safeOpaque(ref.nonce) || !isBookingDateTimeV2(ref.expiresAt)) e.push('$.relaxationApprovalRef: invalid expiry or nonce')
185
- }
186
- if (x.kind === 'question' && (!Array.isArray(x.question?.approvalOptions) || x.question.approvalOptions.length < 1 || x.question.approvalOptions.length > 2)) e.push('$.question.approvalOptions: one or two options required')
187
- return e
188
- }
189
- export function validateBookingSurfaceV2(value: unknown): BookingSurfaceV2ValidationResult { const ok = validate(value); const e = ok ? [] : errors(validate); e.push(...cross(value)); return e.length ? { ok: false, errors: e } : { ok: true } }
190
- function fromCanonical(fn: ValidateFunction, value: unknown): BookingSurfaceV2ValidationResult { const ok = fn(value); const e = ok ? [] : errors(fn); e.push(...cross(value)); return e.length ? { ok: false, errors: e } : { ok: true } }
191
- export function validateBookingReadActionV2(value: unknown): BookingSurfaceV2ValidationResult { return fromCanonical(validateActionCanonical, value) }
192
- export function validateCriterionBlockerV2(value: unknown): BookingSurfaceV2ValidationResult { return fromCanonical(validateBlockerCanonical, value) }
193
- export function validateRelaxationApprovalV2(value: unknown): BookingSurfaceV2ValidationResult { return fromCanonical(validateApprovalCanonical, value) }
194
- export function validateBookingSurfaceEventV2(value: unknown): BookingSurfaceV2ValidationResult { return fromCanonical(validateEventCanonical, value) }
195
- export function validateApprovalAgainstBlocker(approval: unknown, blocker: unknown): BookingSurfaceV2ValidationResult {
196
- const a = approval as any
197
- const b = blocker as any
198
- const blockerResult = validateCriterionBlockerV2(b)
199
- if (!blockerResult.ok) return blockerResult
200
- const approvalResult = validateRelaxationApprovalV2(a)
201
- if (!approvalResult.ok) return approvalResult
202
- const fields = ['blockerId', 'sourceActionId', 'sourceReceiptDigest', 'scope', 'code', 'criterionPath', 'valueDigest']
203
- const mismatches = fields.filter(field => a[field] !== b?.[field])
204
- return mismatches.length ? { ok: false, errors: mismatches.map(field => `$.${field}: approval does not match blocker`) } : { ok: true }
205
- }
@@ -1,165 +0,0 @@
1
- /** Per-agent, per-turn guard for model-driven tool loops. */
2
-
3
- import { randomUUID } from 'node:crypto'
4
- import type { Context } from '@deepseek-ai/cordis'
5
- import type { Agent } from '@deepseek-ai/dsh-agent'
6
- import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
7
-
8
- export const TOOL_BUDGET_SOFT_CALL = 16
9
- export const TOOL_BUDGET_HARD_CALL = 18
10
- export const TOOL_BUDGET_EXHAUSTED = 'TOOL_BUDGET_EXHAUSTED'
11
-
12
- export type ToolBudgetDecision =
13
- | { kind: 'allow'; softSignal: boolean; exhaustsBudget: boolean }
14
- | { kind: 'deny'; reason: string }
15
-
16
- export function toolBudgetDecision(callNumber: number): ToolBudgetDecision {
17
- if (callNumber > TOOL_BUDGET_HARD_CALL) {
18
- return {
19
- kind: 'deny',
20
- reason: `${TOOL_BUDGET_EXHAUSTED}: maximum ${TOOL_BUDGET_HARD_CALL} real tool dispatches per agent turn reached; provide a final answer using existing results`,
21
- }
22
- }
23
- return {
24
- kind: 'allow',
25
- softSignal: callNumber === TOOL_BUDGET_SOFT_CALL,
26
- exhaustsBudget: callNumber === TOOL_BUDGET_HARD_CALL,
27
- }
28
- }
29
-
30
- export function createToolBudgetState(): {
31
- nextCall(): ToolBudgetDecision & { callNumber: number }
32
- } {
33
- let calls = 0
34
- return {
35
- nextCall() {
36
- calls += 1
37
- return { ...toolBudgetDecision(calls), callNumber: calls }
38
- },
39
- }
40
- }
41
-
42
- function convergenceContext(code: 'TOOL_BUDGET_SOFT' | typeof TOOL_BUDGET_EXHAUSTED, text: string) {
43
- return {
44
- id: randomUUID(),
45
- role: 'user' as const,
46
- content: [{ type: 'text' as const, text: `${code}: ${text}` }],
47
- source: { kind: 'plugin' as const, plugin: 'gotry-tool-budget' },
48
- }
49
- }
50
-
51
- function exhaustionResult(reason: string): ToolExecutionResult {
52
- return {
53
- isError: true,
54
- error: {
55
- message: reason,
56
- info: { name: 'ToolBudgetError', code: TOOL_BUDGET_EXHAUSTED },
57
- },
58
- content: [{ type: 'text', text: reason }],
59
- additionalContexts: [convergenceContext(
60
- TOOL_BUDGET_EXHAUSTED,
61
- 'No more tool dispatches are permitted in this turn. Produce the final answer from existing results.',
62
- ) as never],
63
- }
64
- }
65
-
66
- type RestrictableTools = {
67
- schemas?: () => Array<{ name: string }>
68
- restrict?: (filter: { deny: string[] }) => () => void
69
- }
70
-
71
- /**
72
- * Install the runtime boundary, kept separate from the GoTry tool catalog so
73
- * the actual Cordis waterfalls can be exercised without booting every tool.
74
- */
75
- export function installToolBudget(ctx: Context): void {
76
- // Some offline contract tests intentionally provide a registration-only
77
- // Context slice. They do not drive an agent loop, so there is no budget hook
78
- // to install and no reason to require an event bus.
79
- if (typeof (ctx as unknown as { on?: unknown }).on !== 'function') return
80
-
81
- const budgetByAgent = new Map<string, ReturnType<typeof createToolBudgetState>>()
82
- const softExecutions = new Map<ToolExecutionToken, string>()
83
- const pendingFinalOnly = new Map<string, Agent>()
84
- const finalOnlyDisposers = new Map<string, () => void>()
85
-
86
- const clearAgent = (agentId: string) => {
87
- budgetByAgent.delete(agentId)
88
- for (const [token, owner] of softExecutions) {
89
- if (owner === agentId) softExecutions.delete(token)
90
- }
91
- pendingFinalOnly.delete(agentId)
92
- finalOnlyDisposers.get(agentId)?.()
93
- finalOnlyDisposers.delete(agentId)
94
- }
95
-
96
- const enterFinalOnly = (agent: Agent) => {
97
- const agentId = String(agent.id)
98
- if (finalOnlyDisposers.has(agentId)) return
99
- const globalTools = ctx.tools as unknown as RestrictableTools
100
- const scopedTools = agent.ctx?.tools as unknown as RestrictableTools | undefined
101
- if (typeof globalTools.schemas !== 'function' || typeof scopedTools?.restrict !== 'function') return
102
-
103
- // GoTry's model-facing tools are inherited globals. Removing their schemas
104
- // makes the next native-mode request text-only; the execution wrapper below
105
- // still denies any already-prepared same-step call beyond the hard limit.
106
- const inheritedNames = globalTools.schemas()
107
- .map(schema => schema.name)
108
- .filter(name => name !== 'run_code')
109
- if (inheritedNames.length === 0) return
110
- finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }))
111
- }
112
-
113
- ctx.on('session/event', (subject, event) => {
114
- const agentId = String(subject.id)
115
- if (event.type === 'turn/start' || event.type === 'turn/end') {
116
- clearAgent(agentId)
117
- return
118
- }
119
- // The live registry re-checks a tool name before starting every call in
120
- // one assistant response. Restricting immediately on call 18 would turn an
121
- // already-prepared call 19 into an opaque "unknown tool" before the
122
- // execute waterfall can return TOOL_BUDGET_EXHAUSTED. step/end is the
123
- // exact boundary after that batch settles and before the next prompt is
124
- // assembled, so current-step refusals stay structured while the next
125
- // native request is text-only.
126
- if (event.type === 'step/end') {
127
- const agent = pendingFinalOnly.get(agentId)
128
- if (agent) {
129
- pendingFinalOnly.delete(agentId)
130
- enterFinalOnly(agent)
131
- }
132
- }
133
- })
134
- ctx.on('session/disposed', subject => clearAgent(String(subject.id)))
135
-
136
- ctx.on('tools/execute', (exec, next) => {
137
- // Programmatic/direct calls are not part of a model planner turn.
138
- if (!exec.agent) return next()
139
- const agentId = String(exec.agent.id)
140
- const state = budgetByAgent.get(agentId) ?? createToolBudgetState()
141
- budgetByAgent.set(agentId, state)
142
- const decision = state.nextCall()
143
- if (decision.kind === 'deny') {
144
- pendingFinalOnly.set(agentId, exec.agent)
145
- return Promise.resolve(exhaustionResult(decision.reason))
146
- }
147
- if (decision.softSignal) softExecutions.set(exec.token, agentId)
148
- if (decision.exhaustsBudget) pendingFinalOnly.set(agentId, exec.agent)
149
- return next()
150
- })
151
-
152
- ctx.on('tools/post-execute', (exec, _result, next) => {
153
- if (!softExecutions.delete(exec.token)) return next()
154
- return next().then(decision => ({
155
- ...decision,
156
- additionalContexts: [
157
- ...(decision.additionalContexts ?? []),
158
- convergenceContext(
159
- 'TOOL_BUDGET_SOFT',
160
- 'Tool call 16 reached. Converge now and produce the final answer from gathered evidence.',
161
- ) as never,
162
- ],
163
- }))
164
- })
165
- }