@reclaimprotocol/agent 0.1.43 → 0.1.44

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.
@@ -0,0 +1,304 @@
1
+ import { isProblemError, ReclaimClient } from '@reclaimprotocol/client/api'
2
+ import { apiUrl } from '../../../consts.ts'
3
+ import { defineTool, type RegisteredTool } from '../../server.ts'
4
+ import { resolveOrgSecret } from './result.ts'
5
+
6
+ const POLL_MS = 2_000
7
+ const DEFAULT_TIMEOUT_S = 120
8
+ const MAX_TIMEOUT_S = 600
9
+ const MAX_EVENTS = 5
10
+
11
+ type LookupStatus = 'resolving' | 'ready' | 'started' | 'failed'
12
+
13
+ type Lookup = {
14
+ status: LookupStatus
15
+ error?: { title?: string, status?: number, detail?: string }
16
+ dataPoints?: { name: string, isOptional: boolean }[]
17
+ candidates?: { loginUrl: string, source: string, confidence: number }[]
18
+ }
19
+
20
+ type SessionState = {
21
+ status: string
22
+ callbackDeliveryStatus?: string
23
+ verificationUrl?: string
24
+ }
25
+
26
+ type SessionEvent = {
27
+ event: string
28
+ eventData?: Record<string, unknown>
29
+ }
30
+
31
+ /** The three reads the tool needs. Injectable for tests. */
32
+ export type AiSessionApi = {
33
+ getLookup(sessionId: string): Promise<Lookup>
34
+ getSession(sessionId: string): Promise<SessionState>
35
+ listEvents(sessionId: string): Promise<SessionEvent[]>
36
+ }
37
+
38
+ type Deps = {
39
+ createApi?: (orgToken?: string) => AiSessionApi
40
+ sleep?: (ms: number) => Promise<void>
41
+ now?: () => number
42
+ }
43
+
44
+ const FALLBACK_HINT = 'Retry once with adjusted `dataPoints` or `loginUrls`. '
45
+ + 'If that fails too, fall back to the provider route: call '
46
+ + '`start_integration` with `route: \'provider\'`.'
47
+
48
+ /**
49
+ * `wait_for_ai_session` — polls an AI verification session (lookup, session
50
+ * status, and recent events) until something notable happens, and returns a
51
+ * compact, value-free summary with a `next` step and a fallback verdict.
52
+ */
53
+ export function waitForAiSessionTool(deps: Deps = {}): RegisteredTool {
54
+ const createApi = deps.createApi ?? defaultApi
55
+ const sleep = deps.sleep ?? defaultSleep
56
+ const now = deps.now ?? Date.now
57
+
58
+ return defineTool<{
59
+ sessionId: string
60
+ timeoutSeconds?: number
61
+ untilTerminal?: boolean
62
+ orgToken?: string
63
+ }>(
64
+ {
65
+ name: 'wait_for_ai_session',
66
+ description:
67
+ 'Waits for an AI verification session and returns a summary. '
68
+ + 'Polls every 2 seconds until the lookup is ready or failed, or '
69
+ + 'the session ends, or the timeout passes. Set `untilTerminal` to '
70
+ + 'keep waiting until the session ends, for example after you share '
71
+ + 'the verification URL. The result has the lookup state, the session '
72
+ + 'status, recent event types, and `next`: `share_url`, '
73
+ + '`waiting_for_user`, `waiting_for_lookup`, `done`, or `none`. '
74
+ + 'When `fallback.recommended` is true, follow its `reason`. '
75
+ + 'Authenticates with the org API key: `orgToken`, else '
76
+ + '`RECLAIM_ORG_SECRET`, else the key saved by `issue_org_token`.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ required: ['sessionId'],
80
+ properties: {
81
+ sessionId: {
82
+ type: 'string',
83
+ description:
84
+ 'The session ID from `create_ai_verification_session`.',
85
+ },
86
+ timeoutSeconds: {
87
+ type: 'integer',
88
+ minimum: 1,
89
+ maximum: MAX_TIMEOUT_S,
90
+ description: `How long to wait. Default ${DEFAULT_TIMEOUT_S}, `
91
+ + `maximum ${MAX_TIMEOUT_S}.`,
92
+ },
93
+ untilTerminal: {
94
+ type: 'boolean',
95
+ description: 'If true, return only when the session ends or '
96
+ + 'the lookup fails, not when the lookup is ready.',
97
+ },
98
+ orgToken: {
99
+ type: 'string',
100
+ pattern: '^rorg_',
101
+ maxLength: 200,
102
+ description: 'The organization API key (`rorg_…`). Defaults to '
103
+ + 'the MCP server\'s RECLAIM_ORG_SECRET, then the key saved by '
104
+ + '`issue_org_token`. Never echo it back to the user.',
105
+ },
106
+ },
107
+ },
108
+ annotations: { readOnlyHint: true, openWorldHint: true },
109
+ },
110
+ async(args) => {
111
+ const api = createApi(args.orgToken)
112
+ const timeoutS = Math.min(
113
+ Math.max(args.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 1),
114
+ MAX_TIMEOUT_S,
115
+ )
116
+ const deadline = now() + timeoutS * 1000
117
+ let lookup: Lookup = { status: 'resolving' }
118
+ let session: SessionState = { status: 'pending' }
119
+
120
+ while(true) {
121
+ lookup = await tolerate(
122
+ () => api.getLookup(args.sessionId),
123
+ lookup,
124
+ )
125
+ session = await tolerate(
126
+ () => api.getSession(args.sessionId),
127
+ session,
128
+ )
129
+
130
+ const terminal = session.status !== 'pending'
131
+ const failed = lookup.status === 'failed'
132
+ const notable = !args.untilTerminal && lookup.status !== 'resolving'
133
+ const timedOut = now() >= deadline
134
+ if(terminal || failed || notable || timedOut) {
135
+ const events = await tolerate(
136
+ () => api.listEvents(args.sessionId),
137
+ [] as SessionEvent[],
138
+ )
139
+ const settled = terminal || failed || notable
140
+ return summarize(lookup, session, events, timedOut && !settled)
141
+ }
142
+
143
+ await sleep(POLL_MS)
144
+ }
145
+ },
146
+ )
147
+ }
148
+
149
+ function summarize(
150
+ lookup: Lookup,
151
+ session: SessionState,
152
+ events: SessionEvent[],
153
+ timedOut: boolean,
154
+ ) {
155
+ const recent = events.slice(-MAX_EVENTS).map((item) => {
156
+ const reasonCode = reasonCodeOf(item.eventData)
157
+ return reasonCode
158
+ ? { event: item.event, reasonCode }
159
+ : { event: item.event }
160
+ })
161
+ const reasonCodes = recent.flatMap((item) => (
162
+ 'reasonCode' in item ? [item.reasonCode] : []
163
+ ))
164
+
165
+ let next: 'share_url' | 'waiting_for_user' | 'waiting_for_lookup'
166
+ | 'done' | 'none'
167
+ let fallbackReason: string | undefined
168
+ if(lookup.status === 'failed') {
169
+ const status = lookup.error?.status
170
+ const why = status === 404
171
+ ? 'No login page was found.'
172
+ : status === 422
173
+ ? 'The prompt is not a request Reclaim can verify.'
174
+ : status === 503
175
+ ? 'The AI model or web search stayed unavailable.'
176
+ : 'The lookup failed.'
177
+ next = 'none'
178
+ fallbackReason = `${why} ${FALLBACK_HINT}`
179
+ } else if(session.status === 'success') {
180
+ next = 'done'
181
+ } else if(session.status === 'pending') {
182
+ next = lookup.status === 'ready'
183
+ ? 'share_url'
184
+ : lookup.status === 'started'
185
+ ? 'waiting_for_user'
186
+ : 'waiting_for_lookup'
187
+ } else {
188
+ next = 'none'
189
+ if(session.status === 'rejected') {
190
+ const code = reasonCodes.at(-1)
191
+ fallbackReason = `The session was rejected${code ? ` (${code})` : ''}. `
192
+ + FALLBACK_HINT
193
+ } else if(session.status === 'error' || session.status === 'expired') {
194
+ fallbackReason = `The session ended with status ${session.status}. `
195
+ + FALLBACK_HINT
196
+ }
197
+ }
198
+
199
+ return {
200
+ lookup: {
201
+ status: lookup.status,
202
+ ...(lookup.candidates && {
203
+ candidates: lookup.candidates.map((item) => ({
204
+ loginUrl: item.loginUrl,
205
+ source: item.source,
206
+ confidence: item.confidence,
207
+ })),
208
+ }),
209
+ ...(lookup.dataPoints && {
210
+ dataPoints: lookup.dataPoints.map((item) => ({
211
+ name: item.name,
212
+ isOptional: item.isOptional,
213
+ })),
214
+ }),
215
+ ...(lookup.error && {
216
+ error: {
217
+ title: lookup.error.title,
218
+ status: lookup.error.status,
219
+ detail: lookup.error.detail,
220
+ },
221
+ }),
222
+ },
223
+ session: {
224
+ status: session.status,
225
+ callbackDeliveryStatus: session.callbackDeliveryStatus,
226
+ events: recent,
227
+ },
228
+ next,
229
+ ...(next === 'share_url' && session.verificationUrl
230
+ ? { verificationUrl: session.verificationUrl }
231
+ : {}),
232
+ ...(fallbackReason
233
+ ? { fallback: { recommended: true, reason: fallbackReason } }
234
+ : {}),
235
+ ...(timedOut ? { timedOut: true } : {}),
236
+ }
237
+ }
238
+
239
+ function defaultSleep(ms: number) {
240
+ return new Promise<void>((resolve) => setTimeout(resolve, ms))
241
+ }
242
+
243
+ function reasonCodeOf(data: Record<string, unknown> | undefined) {
244
+ const direct = data?.reasonCode
245
+ if(typeof direct === 'string') {
246
+ return direct
247
+ }
248
+
249
+ const problem = data?.problem
250
+ if(problem && typeof problem === 'object' && 'reasonCode' in problem) {
251
+ return typeof problem.reasonCode === 'string'
252
+ ? problem.reasonCode
253
+ : undefined
254
+ }
255
+
256
+ return undefined
257
+ }
258
+
259
+ /** Keeps the last good value across transient errors (network, 5xx, 429). */
260
+ async function tolerate<T>(read: () => Promise<T>, previous: T): Promise<T> {
261
+ try {
262
+ return await read()
263
+ } catch(err) {
264
+ if(err instanceof TypeError) {
265
+ return previous
266
+ }
267
+
268
+ if(isProblemError(err)) {
269
+ const code = err.output.statusCode
270
+ if(code >= 500 || code === 429) {
271
+ return previous
272
+ }
273
+ }
274
+
275
+ throw err
276
+ }
277
+ }
278
+
279
+ function defaultApi(orgToken?: string): AiSessionApi {
280
+ const client = new ReclaimClient({
281
+ baseUrl: apiUrl(),
282
+ token: resolveOrgSecret(orgToken),
283
+ })
284
+ return {
285
+ async getLookup(sessionId) {
286
+ const res = await client.call('GetAiVerificationSession', {
287
+ params: { sessionId },
288
+ })
289
+ return res.data
290
+ },
291
+ async getSession(sessionId) {
292
+ const res = await client.call('GetVerificationSession', {
293
+ params: { sessionId },
294
+ })
295
+ return res.data
296
+ },
297
+ async listEvents(sessionId) {
298
+ const res = await client.call('ListVerificationEvents', {
299
+ params: { sessionId },
300
+ })
301
+ return res.data.items
302
+ },
303
+ }
304
+ }