@irogane/kaji 0.2.0-beta.11

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 (136) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +598 -0
  3. package/contracts/README.md +14 -0
  4. package/contracts/beta-core-v1.json +52 -0
  5. package/contracts/cli/init-cases-v1.json +27 -0
  6. package/contracts/errors/error-codes.json +48 -0
  7. package/contracts/errors/integration-recovery-v1.json +127 -0
  8. package/contracts/errors/provider-normalization.json +111 -0
  9. package/contracts/events/conformance-invalid.json +20 -0
  10. package/contracts/events/conformance.json +511 -0
  11. package/contracts/events/new-kaji-event-v1.schema.json +1021 -0
  12. package/contracts/events/stored-kaji-event-v1.schema.json +1025 -0
  13. package/contracts/feature-tiers-v1.json +488 -0
  14. package/contracts/integrations/abi-index-v1.json +8 -0
  15. package/contracts/integrations/conformance-invalid.json +443 -0
  16. package/contracts/integrations/conformance-valid.json +121 -0
  17. package/contracts/integrations/copy-provenance-v1.schema.json +61 -0
  18. package/contracts/integrations/echo-tool-abi-v1.json +37 -0
  19. package/contracts/integrations/github-api-conformance-v1.json +644 -0
  20. package/contracts/integrations/github-tool-abi-typescript-v1.json +369 -0
  21. package/contracts/integrations/github-tool-abi-v1.json +146 -0
  22. package/contracts/integrations/gmail-api-conformance-v1.json +750 -0
  23. package/contracts/integrations/gmail-tool-abi-v1.json +62 -0
  24. package/contracts/integrations/index.schema.json +37 -0
  25. package/contracts/integrations/manifest.schema.json +119 -0
  26. package/contracts/parity/expected-normalized.json +4906 -0
  27. package/contracts/parity/scenarios.json +100 -0
  28. package/contracts/parity/scenarios.schema.json +214 -0
  29. package/contracts/providers/cost-conformance.json +112 -0
  30. package/contracts/release/github-proof-v1.schema.json +138 -0
  31. package/contracts/release/gmail-proof-v1.schema.json +138 -0
  32. package/contracts/release/kaji-ts-consumer-handoff-v1.schema.json +1289 -0
  33. package/contracts/release/publisher-identity-receipt-v1.schema.json +319 -0
  34. package/contracts/release/typescript-onboarding-evidence-v1.schema.json +740 -0
  35. package/contracts/tools/conformance-invalid.json +271 -0
  36. package/contracts/tools/conformance-valid.json +78 -0
  37. package/contracts/tools/tool-schema-v1.schema.json +18 -0
  38. package/dist/anthropic.cjs +1231 -0
  39. package/dist/anthropic.cjs.map +1 -0
  40. package/dist/anthropic.d.cts +26 -0
  41. package/dist/anthropic.d.ts +26 -0
  42. package/dist/anthropic.js +270 -0
  43. package/dist/anthropic.js.map +1 -0
  44. package/dist/auth.cjs +1507 -0
  45. package/dist/auth.cjs.map +1 -0
  46. package/dist/auth.d.cts +129 -0
  47. package/dist/auth.d.ts +129 -0
  48. package/dist/auth.js +1039 -0
  49. package/dist/auth.js.map +1 -0
  50. package/dist/base-B9FRMcP8.d.cts +140 -0
  51. package/dist/base-nHQd1VtS.d.ts +140 -0
  52. package/dist/chunk-AAM33KAO.js +4367 -0
  53. package/dist/chunk-AAM33KAO.js.map +1 -0
  54. package/dist/chunk-KAJ6BM64.js +153 -0
  55. package/dist/chunk-KAJ6BM64.js.map +1 -0
  56. package/dist/chunk-KCAXIOZS.js +308 -0
  57. package/dist/chunk-KCAXIOZS.js.map +1 -0
  58. package/dist/chunk-LSJ4AVO2.js +243 -0
  59. package/dist/chunk-LSJ4AVO2.js.map +1 -0
  60. package/dist/chunk-TM7ZGOJX.js +716 -0
  61. package/dist/chunk-TM7ZGOJX.js.map +1 -0
  62. package/dist/cli/bin.d.ts +2 -0
  63. package/dist/cli/bin.js +13 -0
  64. package/dist/cli/bin.js.map +1 -0
  65. package/dist/cli/chunk-2RCWPRVY.js +6277 -0
  66. package/dist/cli/chunk-2RCWPRVY.js.map +1 -0
  67. package/dist/cli/chunk-SEBX54TR.js +681 -0
  68. package/dist/cli/chunk-SEBX54TR.js.map +1 -0
  69. package/dist/cli/index.d.ts +232 -0
  70. package/dist/cli/index.js +11 -0
  71. package/dist/cli/index.js.map +1 -0
  72. package/dist/cli/init-worker.d.ts +2 -0
  73. package/dist/cli/init-worker.js +18 -0
  74. package/dist/cli/init-worker.js.map +1 -0
  75. package/dist/cli/integration-copy-worker.js +48 -0
  76. package/dist/cli/integration-copy-worker.js.map +1 -0
  77. package/dist/cli/package-entry-cjs.cjs +21 -0
  78. package/dist/cli/package-entry-cjs.cjs.map +1 -0
  79. package/dist/cli/package-entry-cjs.d.cts +2 -0
  80. package/dist/cli/package-entry.d.ts +2 -0
  81. package/dist/cli/package-entry.js +13 -0
  82. package/dist/cli/package-entry.js.map +1 -0
  83. package/dist/context-BaFHrQHv.d.cts +21 -0
  84. package/dist/context-BaFHrQHv.d.ts +21 -0
  85. package/dist/context-C-YPY-GS.d.cts +1538 -0
  86. package/dist/context-C-YPY-GS.d.ts +1538 -0
  87. package/dist/index.cjs +12346 -0
  88. package/dist/index.cjs.map +1 -0
  89. package/dist/index.d.cts +1560 -0
  90. package/dist/index.d.ts +1560 -0
  91. package/dist/index.js +7852 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/integrations/github.cjs +2092 -0
  94. package/dist/integrations/github.cjs.map +1 -0
  95. package/dist/integrations/github.d.cts +21 -0
  96. package/dist/integrations/github.d.ts +21 -0
  97. package/dist/integrations/github.js +2088 -0
  98. package/dist/integrations/github.js.map +1 -0
  99. package/dist/integrations.cjs +3370 -0
  100. package/dist/integrations.cjs.map +1 -0
  101. package/dist/integrations.d.cts +202 -0
  102. package/dist/integrations.d.ts +202 -0
  103. package/dist/integrations.js +2650 -0
  104. package/dist/integrations.js.map +1 -0
  105. package/dist/observability-Cj--OkME.d.cts +96 -0
  106. package/dist/observability-Cj--OkME.d.ts +96 -0
  107. package/dist/openai.cjs +1235 -0
  108. package/dist/openai.cjs.map +1 -0
  109. package/dist/openai.d.cts +32 -0
  110. package/dist/openai.d.ts +32 -0
  111. package/dist/openai.js +272 -0
  112. package/dist/openai.js.map +1 -0
  113. package/dist/testing.cjs +554 -0
  114. package/dist/testing.cjs.map +1 -0
  115. package/dist/testing.d.cts +43 -0
  116. package/dist/testing.d.ts +43 -0
  117. package/dist/testing.js +118 -0
  118. package/dist/testing.js.map +1 -0
  119. package/package.json +188 -0
  120. package/registry/echo/index.ts +53 -0
  121. package/registry/echo/manifest.json +53 -0
  122. package/registry/github/LICENSE +105 -0
  123. package/registry/github/client.ts +1727 -0
  124. package/registry/github/index.ts +263 -0
  125. package/registry/github/manifest.json +227 -0
  126. package/registry/github/owner-fixtures.json +10 -0
  127. package/registry/github/tests/github.test.ts +32 -0
  128. package/registry/gmail/LICENSE +105 -0
  129. package/registry/gmail/client.ts +548 -0
  130. package/registry/gmail/index.ts +165 -0
  131. package/registry/gmail/manifest.json +102 -0
  132. package/registry/gmail/owner-fixtures.json +10 -0
  133. package/registry/gmail/tests/gmail.test.ts +32 -0
  134. package/registry/index.json +21 -0
  135. package/registry/index.schema.json +37 -0
  136. package/registry/schema.json +119 -0
@@ -0,0 +1,548 @@
1
+ // This is YOUR Gmail integration client. Edit it.
2
+
3
+ import { Buffer } from "node:buffer";
4
+
5
+ import {
6
+ IntegrationAuthRequiredError,
7
+ IntegrationExecutionError,
8
+ IntegrationPolicyError,
9
+ IntegrationRateLimitedError,
10
+ IntegrationTransientReadError,
11
+ snapshotIntegrationResult,
12
+ type BoundedResponse,
13
+ type FixedOriginRequester,
14
+ } from "@irogane/kaji/integrations";
15
+
16
+ type ToolExecutionContext = Parameters<FixedOriginRequester["request"]>[2];
17
+
18
+ const MESSAGE_ID = /^[A-Za-z0-9_-]{1,128}$/;
19
+ const MAX_LIST_RESULT_BYTES = 32 * 1024;
20
+ const MAX_BODY_BYTES = 48 * 1024;
21
+ const MAX_HEADER_VALUE_BYTES = 2 * 1024;
22
+ const MAX_RAW_MESSAGE_BYTES = 1024 * 1024;
23
+ const MAX_MODEL_RESULT_BYTES = 60 * 1024;
24
+ const MAX_TOKEN_CHARACTERS = 4_096;
25
+ const MAX_PAGE_TOKEN_BYTES = 2_048;
26
+ const MAX_PART_DEPTH = 10;
27
+ // Header names we surface, lowercased. Everything else is dropped as noise.
28
+ const SURFACED_HEADERS = new Set(["from", "to", "cc", "subject", "date"]);
29
+ const ACCEPT = "application/json";
30
+
31
+ type Route = "list_messages" | "get_message" | "send_message";
32
+ type Query = Readonly<Record<string, string | number>>;
33
+ type RequestBody = Readonly<Record<string, unknown>>;
34
+
35
+ export interface GmailClientOptions {
36
+ readonly tokenFor: (context: ToolExecutionContext) => Promise<string>;
37
+ readonly http: FixedOriginRequester;
38
+ }
39
+
40
+ interface GmailClientRuntime {
41
+ readonly sleep: (delayMs: number, signal: AbortSignal) => Promise<void>;
42
+ readonly monotonicNow: () => number;
43
+ }
44
+
45
+ function defaultSleep(delayMs: number, signal: AbortSignal): Promise<void> {
46
+ if (signal.aborted) return Promise.reject(signal.reason);
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => {
49
+ signal.removeEventListener("abort", onAbort);
50
+ resolve();
51
+ }, delayMs);
52
+ const onAbort = () => {
53
+ clearTimeout(timer);
54
+ reject(signal.reason);
55
+ };
56
+ signal.addEventListener("abort", onAbort, { once: true });
57
+ });
58
+ }
59
+
60
+ interface RequestJsonOptions {
61
+ readonly method: "GET" | "POST";
62
+ readonly path: string;
63
+ readonly query?: Query;
64
+ readonly body?: RequestBody;
65
+ readonly mutation?: boolean;
66
+ }
67
+
68
+ class ProviderShapeError extends Error {}
69
+
70
+ class UnknownMutationError extends Error {
71
+ readonly error_code = "TOOL_EXECUTION_FAILED";
72
+ readonly reason_code = "gmail_mutation_unknown";
73
+ readonly recovery_code = "RECONCILE_GMAIL_MUTATION";
74
+ readonly doc_url = "https://kaji.dev/docs/integrations/recovery-v1#gmail-mutation-unknown";
75
+
76
+ constructor() {
77
+ super("Gmail mutation outcome is unknown");
78
+ this.name = "UnknownMutationError";
79
+ }
80
+ }
81
+
82
+ function policyError(): IntegrationPolicyError {
83
+ return new IntegrationPolicyError();
84
+ }
85
+
86
+ function authError(): IntegrationAuthRequiredError {
87
+ return new IntegrationAuthRequiredError("gmail_grant_missing");
88
+ }
89
+
90
+ function apiError(): IntegrationExecutionError {
91
+ return new IntegrationExecutionError("api_rejected");
92
+ }
93
+
94
+ function transientError(): IntegrationTransientReadError {
95
+ return new IntegrationTransientReadError();
96
+ }
97
+
98
+ function rateError(): IntegrationRateLimitedError {
99
+ return new IntegrationRateLimitedError();
100
+ }
101
+
102
+ function assertScalarString(value: string, makeError: () => Error): void {
103
+ for (let index = 0; index < value.length; index += 1) {
104
+ const unit = value.charCodeAt(index);
105
+ if (unit >= 0xd800 && unit <= 0xdbff) {
106
+ const next = value.charCodeAt(index + 1);
107
+ if (next < 0xdc00 || next > 0xdfff) throw makeError();
108
+ index += 1;
109
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) {
110
+ throw makeError();
111
+ }
112
+ }
113
+ }
114
+
115
+ function truncateUtf8(value: string, maximum: number): string {
116
+ const bytes = new TextEncoder().encode(value);
117
+ if (bytes.byteLength <= maximum) return value;
118
+ for (let end = maximum; end >= Math.max(0, maximum - 3); end -= 1) {
119
+ try {
120
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes.slice(0, end));
121
+ } catch {
122
+ // A UTF-8 sequence is at most four bytes, so three backtracks suffice.
123
+ }
124
+ }
125
+ throw new ProviderShapeError();
126
+ }
127
+
128
+ function requireMessageId(value: unknown): string {
129
+ if (typeof value !== "string" || !MESSAGE_ID.test(value)) throw policyError();
130
+ return value;
131
+ }
132
+
133
+ function providerMessageId(value: unknown): string {
134
+ if (typeof value !== "string" || !MESSAGE_ID.test(value)) throw new ProviderShapeError();
135
+ return value;
136
+ }
137
+
138
+ function policyString(value: unknown, minimum: number, maximum: number): string {
139
+ if (typeof value !== "string") throw policyError();
140
+ assertScalarString(value, policyError);
141
+ const length = Array.from(value).length;
142
+ if (length < minimum || length > maximum) throw policyError();
143
+ return value;
144
+ }
145
+
146
+ function policyInteger(value: unknown, minimum: number, maximum: number): number {
147
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
148
+ throw policyError();
149
+ }
150
+ return value as number;
151
+ }
152
+
153
+ function providerCharacterString(value: unknown, minimum: number, maximum: number): string {
154
+ if (typeof value !== "string") throw new ProviderShapeError();
155
+ assertScalarString(value, () => new ProviderShapeError());
156
+ const length = Array.from(value).length;
157
+ if (length < minimum || length > maximum) throw new ProviderShapeError();
158
+ return value;
159
+ }
160
+
161
+ function providerInteger(value: unknown, minimum = 0): number {
162
+ if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new ProviderShapeError();
163
+ return value as number;
164
+ }
165
+
166
+ function objectValue(value: unknown): Record<string, unknown> {
167
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
168
+ throw new ProviderShapeError();
169
+ }
170
+ const prototype = Object.getPrototypeOf(value);
171
+ if (prototype !== Object.prototype && prototype !== null) throw new ProviderShapeError();
172
+ return value as Record<string, unknown>;
173
+ }
174
+
175
+ function arrayValue(value: unknown): unknown[] {
176
+ if (!Array.isArray(value)) throw new ProviderShapeError();
177
+ return value;
178
+ }
179
+
180
+ function serializedBytes(value: unknown): number {
181
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
182
+ }
183
+
184
+ /** Decode Gmail's base64url (RFC 4648 §5, unpadded) payloads. */
185
+ function b64urlDecode(value: string): Buffer {
186
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
187
+ const decoded = Buffer.from(normalized, "base64");
188
+ // Round-trip guards against silently-dropped invalid characters.
189
+ if (decoded.toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) {
190
+ throw new ProviderShapeError();
191
+ }
192
+ return decoded;
193
+ }
194
+
195
+ function normalizedToken(value: unknown): string {
196
+ if (typeof value !== "string" || value.includes("\r") || value.includes("\n")) throw authError();
197
+ const token = value.trim();
198
+ if (token.length === 0 || Array.from(token).length > MAX_TOKEN_CHARACTERS) throw authError();
199
+ assertScalarString(token, authError);
200
+ return token;
201
+ }
202
+
203
+ function retryAfter(headers: Readonly<Record<string, string>>): number | undefined {
204
+ const raw = headers["retry-after"];
205
+ if (raw === undefined) return undefined;
206
+ const normalized = raw.trim();
207
+ if (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(normalized)) return undefined;
208
+ const delaySeconds = Number(normalized);
209
+ if (!Number.isFinite(delaySeconds) || delaySeconds < 0 || delaySeconds > 2) return undefined;
210
+ return delaySeconds;
211
+ }
212
+
213
+ function isRateLimited(response: BoundedResponse): boolean {
214
+ return (
215
+ response.status === 429 ||
216
+ (response.status === 403 && retryAfter(response.headers) !== undefined)
217
+ );
218
+ }
219
+
220
+ function validateRawMessage(raw: unknown): string {
221
+ // Caller-supplied, already base64url-encoded. Decode failures are caller
222
+ // error -> policy, not provider shape.
223
+ const value = policyString(raw, 1, MAX_RAW_MESSAGE_BYTES);
224
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
225
+ const decoded = Buffer.from(padded, "base64");
226
+ if (
227
+ decoded.byteLength === 0 ||
228
+ decoded.byteLength > MAX_RAW_MESSAGE_BYTES ||
229
+ decoded.toString("base64").replace(/=+$/, "") !== padded.replace(/=+$/, "")
230
+ ) {
231
+ throw policyError();
232
+ }
233
+ return value;
234
+ }
235
+
236
+ function queryString(query: Query | undefined): string {
237
+ if (query === undefined || Object.keys(query).length === 0) return "";
238
+ const pairs: string[] = [];
239
+ for (const key of Object.keys(query).sort()) {
240
+ const value = query[key];
241
+ if (key.length === 0 || (typeof value !== "string" && typeof value !== "number")) {
242
+ throw policyError();
243
+ }
244
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
245
+ }
246
+ return `?${pairs.join("&")}`;
247
+ }
248
+
249
+ function routeFor(options: RequestJsonOptions): Route {
250
+ const { method, path, query, body, mutation = false } = options;
251
+ const base = "/gmail/v1/users/me/messages";
252
+ const queryKeys = new Set(Object.keys(query ?? {}));
253
+ if (method === "GET" && !mutation && path === base) {
254
+ if (query === undefined || body !== undefined) throw policyError();
255
+ for (const key of queryKeys)
256
+ if (key !== "q" && key !== "maxResults" && key !== "pageToken") throw policyError();
257
+ if (!queryKeys.has("maxResults")) throw policyError();
258
+ policyInteger(query.maxResults, 1, 100);
259
+ if (query.q !== undefined) policyString(query.q, 1, 1_024);
260
+ if (query.pageToken !== undefined) policyString(query.pageToken, 1, MAX_PAGE_TOKEN_BYTES);
261
+ return "list_messages";
262
+ }
263
+ if (method === "POST" && mutation && path === `${base}/send`) {
264
+ if (query !== undefined || body === undefined) throw policyError();
265
+ if (Object.keys(body).length !== 1 || !("raw" in body)) throw policyError();
266
+ validateRawMessage(body.raw);
267
+ return "send_message";
268
+ }
269
+ if (method === "GET" && !mutation && path.startsWith(`${base}/`)) {
270
+ if (body !== undefined) throw policyError();
271
+ for (const key of queryKeys) if (key !== "format") throw policyError();
272
+ requireMessageId(path.slice(`${base}/`.length));
273
+ if (query?.format !== undefined && query.format !== "full") throw policyError();
274
+ return "get_message";
275
+ }
276
+ throw policyError();
277
+ }
278
+
279
+ function isAbortOrTimeout(error: unknown): boolean {
280
+ return (
281
+ error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")
282
+ );
283
+ }
284
+
285
+ export class GmailClient {
286
+ private readonly tokenFor: (context: ToolExecutionContext) => Promise<string>;
287
+ private readonly http: FixedOriginRequester;
288
+ private readonly sleep: GmailClientRuntime["sleep"];
289
+ private readonly monotonicNow: GmailClientRuntime["monotonicNow"];
290
+
291
+ constructor(
292
+ options: GmailClientOptions,
293
+ runtime: GmailClientRuntime = { sleep: defaultSleep, monotonicNow: () => performance.now() },
294
+ ) {
295
+ if (
296
+ typeof options?.tokenFor !== "function" ||
297
+ typeof options.http?.request !== "function" ||
298
+ typeof runtime.sleep !== "function" ||
299
+ typeof runtime.monotonicNow !== "function"
300
+ ) {
301
+ throw policyError();
302
+ }
303
+ this.tokenFor = options.tokenFor;
304
+ this.http = options.http;
305
+ this.sleep = runtime.sleep;
306
+ this.monotonicNow = runtime.monotonicNow;
307
+ }
308
+
309
+ async listMessages(
310
+ context: ToolExecutionContext,
311
+ input: Readonly<{ query?: string; maxResults?: number; pageToken?: string }>,
312
+ ): Promise<unknown> {
313
+ const maxResults = input.maxResults ?? 10;
314
+ policyInteger(maxResults, 1, 100);
315
+ const query: Record<string, string | number> = { maxResults };
316
+ if (input.query !== undefined) query.q = policyString(input.query, 1, 1_024);
317
+ if (input.pageToken !== undefined)
318
+ query.pageToken = policyString(input.pageToken, 1, MAX_PAGE_TOKEN_BYTES);
319
+ return this.requestJson(context, {
320
+ method: "GET",
321
+ path: "/gmail/v1/users/me/messages",
322
+ query,
323
+ });
324
+ }
325
+
326
+ async getMessage(
327
+ context: ToolExecutionContext,
328
+ input: Readonly<{ messageId: string }>,
329
+ ): Promise<unknown> {
330
+ const identifier = requireMessageId(input.messageId);
331
+ return this.requestJson(context, {
332
+ method: "GET",
333
+ path: `/gmail/v1/users/me/messages/${identifier}`,
334
+ query: { format: "full" },
335
+ });
336
+ }
337
+
338
+ async sendMessage(
339
+ context: ToolExecutionContext,
340
+ input: Readonly<{ raw: string }>,
341
+ ): Promise<unknown> {
342
+ validateRawMessage(input.raw);
343
+ return this.requestJson(context, {
344
+ method: "POST",
345
+ path: "/gmail/v1/users/me/messages/send",
346
+ body: { raw: input.raw },
347
+ mutation: true,
348
+ });
349
+ }
350
+
351
+ async requestJson(context: ToolExecutionContext, options: RequestJsonOptions): Promise<unknown> {
352
+ const route = routeFor(options);
353
+ const mutation = options.mutation ?? false;
354
+ const pathAndQuery = options.path + queryString(options.query);
355
+ const requestBody =
356
+ options.body === undefined
357
+ ? undefined
358
+ : new TextEncoder().encode(JSON.stringify(snapshotIntegrationResult(options.body)));
359
+
360
+ if (context.signal.aborted) throw context.signal.reason;
361
+ let token: string;
362
+ try {
363
+ token = await this.tokenFor(context);
364
+ } catch (error) {
365
+ if (isAbortOrTimeout(error) || error instanceof IntegrationExecutionError) throw error;
366
+ throw authError();
367
+ }
368
+ if (context.signal.aborted) throw context.signal.reason;
369
+ const headers = Object.freeze({
370
+ accept: ACCEPT,
371
+ authorization: `Bearer ${normalizedToken(token)}`,
372
+ ...(requestBody === undefined ? {} : { "content-type": "application/json" }),
373
+ });
374
+
375
+ let response: BoundedResponse | undefined;
376
+ for (let attempt = 0; attempt < 2; attempt += 1) {
377
+ try {
378
+ response = await this.http.request(
379
+ pathAndQuery,
380
+ {
381
+ method: options.method,
382
+ headers,
383
+ ...(requestBody === undefined ? {} : { body: requestBody }),
384
+ },
385
+ context,
386
+ );
387
+ } catch (error) {
388
+ if (isAbortOrTimeout(error) || error instanceof IntegrationExecutionError) throw error;
389
+ if (mutation) throw new UnknownMutationError();
390
+ throw transientError();
391
+ }
392
+
393
+ if (isRateLimited(response)) {
394
+ const delay = retryAfter(response.headers);
395
+ if (
396
+ options.method === "GET" &&
397
+ attempt === 0 &&
398
+ delay !== undefined &&
399
+ this.deadlineAllows(context, delay * 1_000)
400
+ ) {
401
+ await this.sleep(delay * 1_000, context.signal);
402
+ if (context.signal.aborted) throw context.signal.reason;
403
+ continue;
404
+ }
405
+ throw rateError();
406
+ }
407
+ break;
408
+ }
409
+ if (response === undefined) throw rateError();
410
+
411
+ if (response.status === 401) throw authError();
412
+ if (response.status === 403) throw apiError();
413
+ if (response.status < 200 || response.status >= 300) {
414
+ if (mutation && response.status >= 500) throw new UnknownMutationError();
415
+ if (response.status === 404 || response.status === 422 || response.status < 500) {
416
+ throw apiError();
417
+ }
418
+ if (mutation) throw new UnknownMutationError();
419
+ throw transientError();
420
+ }
421
+
422
+ try {
423
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(response.bytes);
424
+ const document: unknown = JSON.parse(decoded);
425
+ return snapshotIntegrationResult(this.normalize(route, document));
426
+ } catch {
427
+ if (mutation) throw new UnknownMutationError();
428
+ throw transientError();
429
+ }
430
+ }
431
+
432
+ private deadlineAllows(context: ToolExecutionContext, delayMs: number): boolean {
433
+ return (
434
+ context.deadlineMonotonicMs === undefined ||
435
+ context.deadlineMonotonicMs - this.monotonicNow() > delayMs
436
+ );
437
+ }
438
+
439
+ private normalize(route: Route, document: unknown): unknown {
440
+ if (route === "list_messages") return this.normalizeList(document);
441
+ if (route === "get_message") return this.normalizeMessage(document);
442
+ return this.normalizeSend(document);
443
+ }
444
+
445
+ private normalizeList(document: unknown): unknown {
446
+ const root = objectValue(document);
447
+ const rows = root.messages === undefined ? [] : arrayValue(root.messages);
448
+ const messages = rows.slice(0, 100).map((value) => {
449
+ const message = objectValue(value);
450
+ return {
451
+ id: providerMessageId(message.id),
452
+ thread_id: providerMessageId(message.threadId),
453
+ };
454
+ });
455
+ const result: Record<string, unknown> = { messages };
456
+ if (root.resultSizeEstimate !== undefined) {
457
+ result.result_size_estimate = providerInteger(root.resultSizeEstimate);
458
+ }
459
+ if (root.nextPageToken !== undefined) {
460
+ result.next_page_token = providerCharacterString(root.nextPageToken, 1, MAX_PAGE_TOKEN_BYTES);
461
+ }
462
+ if (serializedBytes(result) > MAX_LIST_RESULT_BYTES) throw new ProviderShapeError();
463
+ return result;
464
+ }
465
+
466
+ private normalizeMessage(document: unknown): unknown {
467
+ const root = objectValue(document);
468
+ const payload = objectValue(root.payload);
469
+ const headers: Record<string, string> = {};
470
+ const rawHeaders = payload.headers === undefined ? [] : arrayValue(payload.headers);
471
+ for (const value of rawHeaders) {
472
+ const header = objectValue(value);
473
+ const name = providerCharacterString(header.name, 1, 256).toLowerCase();
474
+ if (SURFACED_HEADERS.has(name) && !(name in headers)) {
475
+ headers[name] = truncateUtf8(
476
+ providerCharacterString(header.value, 0, MAX_HEADER_VALUE_BYTES),
477
+ MAX_HEADER_VALUE_BYTES,
478
+ );
479
+ }
480
+ }
481
+ const { text, truncated } = this.extractBody(payload);
482
+ const result = {
483
+ id: providerMessageId(root.id),
484
+ thread_id: providerMessageId(root.threadId),
485
+ snippet: truncateUtf8(
486
+ providerCharacterString(root.snippet ?? "", 0, MAX_RAW_MESSAGE_BYTES),
487
+ 1_024,
488
+ ),
489
+ headers,
490
+ body: text,
491
+ body_truncated: truncated,
492
+ };
493
+ // ponytail: TS message-result ceiling is 60K, the shared TS-SDK model-result
494
+ // convention (see github/client.ts MAX_MODEL_RESULT_BYTES). Python bounds the
495
+ // same path at its 64K durable-tool-result limit; the 4K gap is a deliberate
496
+ // per-SDK ceiling, not a bug. Unify only if a conformance case requires an
497
+ // exact cross-SDK byte boundary.
498
+ if (serializedBytes(result) > MAX_MODEL_RESULT_BYTES) throw new ProviderShapeError();
499
+ return result;
500
+ }
501
+
502
+ private extractBody(payload: Record<string, unknown>): { text: string; truncated: boolean } {
503
+ for (const part of this.walkParts(payload, 0)) {
504
+ if (part.mimeType !== "text/plain") continue;
505
+ const body = part.body;
506
+ if (body === null || typeof body !== "object" || Array.isArray(body)) continue;
507
+ const data = (body as Record<string, unknown>).data;
508
+ if (typeof data !== "string" || data.length === 0) continue;
509
+ const decoded = b64urlDecode(data);
510
+ if (decoded.byteLength > MAX_BODY_BYTES) {
511
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(
512
+ decoded.subarray(0, MAX_BODY_BYTES),
513
+ );
514
+ return { text: truncateUtf8(text, MAX_BODY_BYTES), truncated: true };
515
+ }
516
+ try {
517
+ return {
518
+ text: new TextDecoder("utf-8", { fatal: true }).decode(decoded),
519
+ truncated: false,
520
+ };
521
+ } catch {
522
+ throw new ProviderShapeError();
523
+ }
524
+ }
525
+ return { text: "", truncated: false };
526
+ }
527
+
528
+ private walkParts(part: Record<string, unknown>, depth: number): Record<string, unknown>[] {
529
+ // ponytail: depth cap 10 stops a hostile deeply-nested MIME tree; real mail
530
+ // nests 2-3 levels. Raise if Gmail ever legitimately exceeds it.
531
+ if (depth > MAX_PART_DEPTH) throw new ProviderShapeError();
532
+ const result = [part];
533
+ if (part.parts !== undefined) {
534
+ for (const child of arrayValue(part.parts)) {
535
+ result.push(...this.walkParts(objectValue(child), depth + 1));
536
+ }
537
+ }
538
+ return result;
539
+ }
540
+
541
+ private normalizeSend(document: unknown): unknown {
542
+ const message = objectValue(document);
543
+ return {
544
+ id: providerMessageId(message.id),
545
+ thread_id: providerMessageId(message.threadId),
546
+ };
547
+ }
548
+ }