@danceiny/gotry 0.0.1-rc.18 → 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.
- package/README.md +42 -16
- package/README.zh-CN.md +37 -15
- package/bin/gotry-bootstrap.js +236 -3
- package/bin/gotry-inner.js +66 -20
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +15 -5
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +285 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
- package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +87 -2
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +121 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +7 -4
- package/dist/src/benchmark-environment-bridge.js +68 -7
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +735 -149
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +6 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/package.json +1 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +14 -6
- package/ts/src/benchmark-environment-bridge.ts +29 -10
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +489 -171
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +361 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- 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
|
-
}
|
package/ts/src/tool-budget.ts
DELETED
|
@@ -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
|
-
}
|