la-machina-engine 0.15.0 → 0.16.0

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/dist/index.cjs CHANGED
@@ -1261,6 +1261,130 @@ var ApiAuthSchema = import_zod.z.discriminatedUnion("type", [
1261
1261
  }).strict(),
1262
1262
  import_zod.z.object({ type: import_zod.z.literal("custom"), id: import_zod.z.string().min(1) }).strict()
1263
1263
  ]);
1264
+ var ApiPaginationModeEnum = import_zod.z.enum(["offset", "page", "cursor", "link-header"]);
1265
+ var ApiPaginationStopEnum = import_zod.z.enum([
1266
+ "empty-page",
1267
+ "total-reached",
1268
+ "missing-cursor",
1269
+ "missing-next-link"
1270
+ ]);
1271
+ var PAGINATION_MAX_LIMIT = 1e3;
1272
+ var ApiPaginationSchema = import_zod.z.object({
1273
+ mode: ApiPaginationModeEnum,
1274
+ request: import_zod.z.object({
1275
+ limitParam: import_zod.z.string().min(1).optional(),
1276
+ offsetParam: import_zod.z.string().min(1).optional(),
1277
+ pageParam: import_zod.z.string().min(1).optional(),
1278
+ cursorParam: import_zod.z.string().min(1).optional(),
1279
+ maxLimit: import_zod.z.number().int().positive().max(PAGINATION_MAX_LIMIT).optional(),
1280
+ firstPage: import_zod.z.number().int().nonnegative().optional()
1281
+ }).strict().optional(),
1282
+ response: import_zod.z.object({
1283
+ itemsPath: import_zod.z.string().min(1).optional(),
1284
+ totalPath: import_zod.z.string().min(1).optional(),
1285
+ nextCursorPath: import_zod.z.string().min(1).optional(),
1286
+ nextLinkPath: import_zod.z.string().min(1).optional()
1287
+ }).strict().optional(),
1288
+ stop: import_zod.z.object({
1289
+ strategy: ApiPaginationStopEnum
1290
+ }).strict().optional()
1291
+ }).strict().superRefine((p, ctx) => {
1292
+ const req2 = p.request ?? {};
1293
+ const res = p.response ?? {};
1294
+ if (p.mode === "offset") {
1295
+ if (req2.limitParam === void 0) {
1296
+ ctx.addIssue({
1297
+ code: "custom",
1298
+ message: "pagination.mode=offset requires request.limitParam",
1299
+ path: ["request", "limitParam"]
1300
+ });
1301
+ }
1302
+ if (req2.offsetParam === void 0) {
1303
+ ctx.addIssue({
1304
+ code: "custom",
1305
+ message: "pagination.mode=offset requires request.offsetParam",
1306
+ path: ["request", "offsetParam"]
1307
+ });
1308
+ }
1309
+ if (req2.maxLimit === void 0) {
1310
+ ctx.addIssue({
1311
+ code: "custom",
1312
+ message: "pagination.mode=offset requires request.maxLimit",
1313
+ path: ["request", "maxLimit"]
1314
+ });
1315
+ }
1316
+ if (res.itemsPath === void 0) {
1317
+ ctx.addIssue({
1318
+ code: "custom",
1319
+ message: "pagination.mode=offset requires response.itemsPath",
1320
+ path: ["response", "itemsPath"]
1321
+ });
1322
+ }
1323
+ } else if (p.mode === "page") {
1324
+ if (req2.pageParam === void 0) {
1325
+ ctx.addIssue({
1326
+ code: "custom",
1327
+ message: "pagination.mode=page requires request.pageParam",
1328
+ path: ["request", "pageParam"]
1329
+ });
1330
+ }
1331
+ if (req2.maxLimit === void 0) {
1332
+ ctx.addIssue({
1333
+ code: "custom",
1334
+ message: "pagination.mode=page requires request.maxLimit",
1335
+ path: ["request", "maxLimit"]
1336
+ });
1337
+ }
1338
+ if (res.itemsPath === void 0) {
1339
+ ctx.addIssue({
1340
+ code: "custom",
1341
+ message: "pagination.mode=page requires response.itemsPath",
1342
+ path: ["response", "itemsPath"]
1343
+ });
1344
+ }
1345
+ } else if (p.mode === "cursor") {
1346
+ if (req2.cursorParam === void 0) {
1347
+ ctx.addIssue({
1348
+ code: "custom",
1349
+ message: "pagination.mode=cursor requires request.cursorParam",
1350
+ path: ["request", "cursorParam"]
1351
+ });
1352
+ }
1353
+ if (res.nextCursorPath === void 0) {
1354
+ ctx.addIssue({
1355
+ code: "custom",
1356
+ message: "pagination.mode=cursor requires response.nextCursorPath",
1357
+ path: ["response", "nextCursorPath"]
1358
+ });
1359
+ }
1360
+ if (res.itemsPath === void 0) {
1361
+ ctx.addIssue({
1362
+ code: "custom",
1363
+ message: "pagination.mode=cursor requires response.itemsPath",
1364
+ path: ["response", "itemsPath"]
1365
+ });
1366
+ }
1367
+ } else if (p.mode === "link-header") {
1368
+ if (res.nextLinkPath === void 0) {
1369
+ ctx.addIssue({
1370
+ code: "custom",
1371
+ message: "pagination.mode=link-header requires response.nextLinkPath (HTTP Link header parsing is deferred)",
1372
+ path: ["response", "nextLinkPath"]
1373
+ });
1374
+ }
1375
+ if (res.itemsPath === void 0) {
1376
+ ctx.addIssue({
1377
+ code: "custom",
1378
+ message: "pagination.mode=link-header requires response.itemsPath",
1379
+ path: ["response", "itemsPath"]
1380
+ });
1381
+ }
1382
+ }
1383
+ });
1384
+ var ApiResponseMappingSchema = import_zod.z.object({
1385
+ itemsPath: import_zod.z.string().min(1).optional(),
1386
+ totalPath: import_zod.z.string().min(1).optional()
1387
+ }).strict();
1264
1388
  var ApiEndpointSchema = import_zod.z.object({
1265
1389
  method: ApiHttpMethodEnum,
1266
1390
  path: import_zod.z.string().regex(/^\//, "path must start with /"),
@@ -1269,7 +1393,10 @@ var ApiEndpointSchema = import_zod.z.object({
1269
1393
  // time by higher-level tooling (nikaido's yaml compiler), not
1270
1394
  // here.
1271
1395
  inputSchema: import_zod.z.unknown().optional(),
1272
- outputHint: import_zod.z.string().optional()
1396
+ outputHint: import_zod.z.string().optional(),
1397
+ // Plan 050 — declarative pagination + response extraction.
1398
+ pagination: ApiPaginationSchema.optional(),
1399
+ response: ApiResponseMappingSchema.optional()
1273
1400
  }).strict();
1274
1401
  var HEADER_NAME_RE = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
1275
1402
  var RESERVED_HEADER_NAMES = /* @__PURE__ */ new Set([
@@ -1278,6 +1405,14 @@ var RESERVED_HEADER_NAMES = /* @__PURE__ */ new Set([
1278
1405
  "set-cookie",
1279
1406
  "proxy-authorization"
1280
1407
  ]);
1408
+ var SECRET_PATTERN_RE = /(-secret|-token|-key)$/i;
1409
+ var RESERVED_DEFAULT_HEADER_EXACT = /* @__PURE__ */ new Set([
1410
+ "authorization",
1411
+ "cookie",
1412
+ "set-cookie",
1413
+ "proxy-authorization",
1414
+ "x-auth-token"
1415
+ ]);
1281
1416
  var ApiServiceSchema = import_zod.z.object({
1282
1417
  name: import_zod.z.string().min(1),
1283
1418
  description: import_zod.z.string().optional(),
@@ -1292,6 +1427,34 @@ var ApiServiceSchema = import_zod.z.object({
1292
1427
  endpoints: import_zod.z.array(ApiEndpointSchema).optional(),
1293
1428
  secretHeaders: import_zod.z.record(import_zod.z.string(), import_zod.z.string().min(1)).optional()
1294
1429
  }).strict().superRefine((svc, ctx) => {
1430
+ if (svc.defaultHeaders !== void 0) {
1431
+ for (const headerName of Object.keys(svc.defaultHeaders)) {
1432
+ if (!HEADER_NAME_RE.test(headerName)) {
1433
+ ctx.addIssue({
1434
+ code: "custom",
1435
+ message: `defaultHeaders key "${headerName}" is not a valid HTTP header name (RFC 7230 token charset)`,
1436
+ path: ["defaultHeaders", headerName]
1437
+ });
1438
+ continue;
1439
+ }
1440
+ const lower = headerName.toLowerCase();
1441
+ if (RESERVED_DEFAULT_HEADER_EXACT.has(lower)) {
1442
+ ctx.addIssue({
1443
+ code: "custom",
1444
+ message: `defaultHeaders key "${headerName}" is reserved \u2014 move secret-bearing values to secretHeaders or primary auth`,
1445
+ path: ["defaultHeaders", headerName]
1446
+ });
1447
+ continue;
1448
+ }
1449
+ if (SECRET_PATTERN_RE.test(headerName)) {
1450
+ ctx.addIssue({
1451
+ code: "custom",
1452
+ message: `defaultHeaders key "${headerName}" looks like a secret-bearing name (matches *-secret/*-token/*-key). Move to secretHeaders so the value is vault-resolved + scrubbed.`,
1453
+ path: ["defaultHeaders", headerName]
1454
+ });
1455
+ }
1456
+ }
1457
+ }
1295
1458
  if (svc.secretHeaders === void 0) return;
1296
1459
  const lowerDefault = /* @__PURE__ */ new Set();
1297
1460
  for (const k of Object.keys(svc.defaultHeaders ?? {})) lowerDefault.add(k.toLowerCase());
@@ -8124,6 +8287,11 @@ function getApiServicesSection(opts) {
8124
8287
  "Configured external HTTP APIs. Use the `ApiCall` tool to invoke \u2014 auth is injected automatically. Do not pass credentials via headers."
8125
8288
  );
8126
8289
  lines.push("");
8290
+ appendStrictApiToolRules(
8291
+ lines,
8292
+ /* hasLazy */
8293
+ false
8294
+ );
8127
8295
  for (const svc of withEndpoints) {
8128
8296
  lines.push(`## ${svc.name}${svc.description ? ` \u2014 ${svc.description}` : ""}`);
8129
8297
  lines.push(`baseUrl: ${svc.baseUrl}`);
@@ -8152,6 +8320,11 @@ function getApiServicesSection(opts) {
8152
8320
  "Configured external HTTP APIs. Use `ApiCall` to invoke, but first call `DescribeService(service)` to fetch that service's endpoint catalog. Auth is injected automatically."
8153
8321
  );
8154
8322
  lines.push("");
8323
+ appendStrictApiToolRules(
8324
+ lines,
8325
+ /* hasLazy */
8326
+ true
8327
+ );
8155
8328
  for (const svc of withEndpoints) {
8156
8329
  const count = svc.endpoints.length;
8157
8330
  const suffix = svc.description ? ` \u2014 ${svc.description}` : "";
@@ -8171,6 +8344,24 @@ function getApiServicesSection(opts) {
8171
8344
  }
8172
8345
  return lines.join("\n").trimEnd();
8173
8346
  }
8347
+ function appendStrictApiToolRules(lines, hasLazy) {
8348
+ lines.push("Rules:");
8349
+ if (hasLazy) {
8350
+ lines.push(
8351
+ "- Call `DescribeService(name)` before `ApiCall` for any service shown above. The catalog gives you method, path, input schema, and (when declared) pagination + response-extraction metadata."
8352
+ );
8353
+ }
8354
+ lines.push(
8355
+ "- Auth headers (Authorization, X-API-Key, secret headers) are injected by the host. Never pass credentials via `ApiCall.headers`; the runtime drops them and your call may fail without them."
8356
+ );
8357
+ lines.push(
8358
+ "- For endpoints whose catalog entry declares `pagination`, prefer `ApiCall` with `pagination.auto: true` over manually issuing one call per page. The engine then handles request mutation, item extraction, and stop conditions deterministically."
8359
+ );
8360
+ lines.push(
8361
+ "- Tool results may be returned as opaque references when payloads are large. Inspect or otherwise read the shaped fields before deciding next steps. Never finalize using a raw reference id as data, and never include reference ids in user-visible output unless explicitly asked for debugging."
8362
+ );
8363
+ lines.push("");
8364
+ }
8174
8365
  function resolveEffectiveMode(services, requested, threshold) {
8175
8366
  if (requested === "eager" || requested === "lazy") return requested;
8176
8367
  let total = 0;
@@ -8400,6 +8591,10 @@ init_contract();
8400
8591
  var ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
8401
8592
  var DEFAULT_MAX_BODY_BYTES = 256 * 1024;
8402
8593
  var DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
8594
+ var DEFAULT_MAX_PAGES = 5;
8595
+ var MAX_PAGES_HARD_CAP = 50;
8596
+ var DEFAULT_MAX_ITEMS = 500;
8597
+ var MAX_ITEMS_HARD_CAP = 1e4;
8403
8598
  function createApiCallTool(opts) {
8404
8599
  if (opts.services.length === 0) {
8405
8600
  throw new Error("createApiCallTool: services list must be non-empty");
@@ -8446,7 +8641,21 @@ function createApiCallTool(opts) {
8446
8641
  * ingestion, image push, etc.).
8447
8642
  */
8448
8643
  bodyEncoding: import_zod23.z.enum(["json", "raw"]).optional(),
8449
- headers: import_zod23.z.record(import_zod23.z.string(), import_zod23.z.string()).optional()
8644
+ headers: import_zod23.z.record(import_zod23.z.string(), import_zod23.z.string()).optional(),
8645
+ /**
8646
+ * Plan 050 — opt-in auto-pagination. When `auto: true`, the
8647
+ * engine looks up the endpoint's pagination metadata from the
8648
+ * service catalog and loops pages deterministically. Without
8649
+ * `auto`, ApiCall behaves as a single-shot HTTP call (pre-050).
8650
+ *
8651
+ * `maxPages` defaults to 5, hard cap 50.
8652
+ * `maxItems` defaults to 500, hard cap 10000.
8653
+ */
8654
+ pagination: import_zod23.z.object({
8655
+ auto: import_zod23.z.boolean().optional().default(false),
8656
+ maxPages: import_zod23.z.number().int().min(1).max(MAX_PAGES_HARD_CAP).optional(),
8657
+ maxItems: import_zod23.z.number().int().min(1).max(MAX_ITEMS_HARD_CAP).optional()
8658
+ }).optional()
8450
8659
  });
8451
8660
  const description = opts.toolDescription ?? `Call a configured external API. Services: ${serviceNames.join(", ")}. Auth is injected automatically \u2014 do not pass credentials via headers.`;
8452
8661
  return defineTool({
@@ -8468,6 +8677,18 @@ function createApiCallTool(opts) {
8468
8677
  if (!pathAllowed(input.path, effectivePaths)) {
8469
8678
  return errResult(`ERR_API_PATH_NOT_ALLOWED: ${input.path} for service ${svc.name}`);
8470
8679
  }
8680
+ if (input.pagination?.auto === true) {
8681
+ return executeAutoPaginated({
8682
+ svc,
8683
+ input,
8684
+ fetchFn,
8685
+ maxResponseBytes,
8686
+ env: opts.env,
8687
+ resolveAuth: opts.resolveAuth,
8688
+ onRequest: opts.onRequest,
8689
+ onResponse: opts.onResponse
8690
+ });
8691
+ }
8471
8692
  let bodyText;
8472
8693
  let defaultContentType;
8473
8694
  if (input.body !== void 0) {
@@ -8666,6 +8887,287 @@ async function invokeHook(hook, event) {
8666
8887
  } catch {
8667
8888
  }
8668
8889
  }
8890
+ function matchEndpoint(endpoints, method, path) {
8891
+ if (endpoints === void 0 || endpoints.length === 0) return { error: "NO_MATCH" };
8892
+ const exact = [];
8893
+ const templated = [];
8894
+ for (const ep of endpoints) {
8895
+ if (ep.method !== method) continue;
8896
+ if (ep.path === path) {
8897
+ exact.push(ep);
8898
+ continue;
8899
+ }
8900
+ if (ep.path.includes("{") && pathMatchesTemplate(ep.path, path)) {
8901
+ templated.push(ep);
8902
+ }
8903
+ }
8904
+ if (exact.length === 1) return { endpoint: exact[0] };
8905
+ if (exact.length > 1) return { error: "AMBIGUOUS" };
8906
+ if (templated.length === 1) return { endpoint: templated[0] };
8907
+ if (templated.length > 1) return { error: "AMBIGUOUS" };
8908
+ return { error: "NO_MATCH" };
8909
+ }
8910
+ function pathMatchesTemplate(template, concrete) {
8911
+ const pattern = "^" + template.split(/(\{[^}]+\})/).map(
8912
+ (seg) => seg.startsWith("{") && seg.endsWith("}") ? "[^/]+" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
8913
+ ).join("") + "$";
8914
+ return new RegExp(pattern).test(concrete);
8915
+ }
8916
+ function extractByDotPath(value, path) {
8917
+ if (path.length === 0) return value;
8918
+ let cur = value;
8919
+ for (const seg of path.split(".")) {
8920
+ if (cur === null || typeof cur !== "object") return void 0;
8921
+ cur = cur[seg];
8922
+ }
8923
+ return cur;
8924
+ }
8925
+ function asItemsArray(value) {
8926
+ return Array.isArray(value) ? value : null;
8927
+ }
8928
+ function asTotalCount(value) {
8929
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
8930
+ return Math.floor(value);
8931
+ }
8932
+ if (typeof value === "string") {
8933
+ const n = Number(value);
8934
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
8935
+ }
8936
+ return null;
8937
+ }
8938
+ async function executeAutoPaginated(args) {
8939
+ const { svc, input, fetchFn, maxResponseBytes } = args;
8940
+ const matched = matchEndpoint(svc.endpoints, input.method, input.path);
8941
+ if ("error" in matched) {
8942
+ if (matched.error === "NO_MATCH") {
8943
+ return errResult(
8944
+ `ERR_API_PAGINATION_NO_METADATA: no catalog entry for ${input.method} ${input.path} on service ${svc.name} \u2014 auto-pagination requires endpoints[].pagination`
8945
+ );
8946
+ }
8947
+ return errResult(
8948
+ `ERR_API_ENDPOINT_AMBIGUOUS: ${input.method} ${input.path} matched multiple catalog entries on service ${svc.name}`
8949
+ );
8950
+ }
8951
+ const endpoint = matched.endpoint;
8952
+ const p = endpoint.pagination;
8953
+ if (p === void 0) {
8954
+ return errResult(
8955
+ `ERR_API_PAGINATION_NO_METADATA: endpoint ${input.method} ${input.path} on service ${svc.name} has no pagination metadata; remove pagination.auto or declare it in the catalog`
8956
+ );
8957
+ }
8958
+ const itemsPath = p.response?.itemsPath ?? endpoint.response?.itemsPath;
8959
+ if (itemsPath === void 0) {
8960
+ return errResult(
8961
+ `ERR_API_PAGINATION_NO_METADATA: endpoint ${input.method} ${input.path} declares pagination but no response.itemsPath; cannot extract items`
8962
+ );
8963
+ }
8964
+ const maxPages = Math.min(input.pagination?.maxPages ?? DEFAULT_MAX_PAGES, MAX_PAGES_HARD_CAP);
8965
+ const maxItems = Math.min(input.pagination?.maxItems ?? DEFAULT_MAX_ITEMS, MAX_ITEMS_HARD_CAP);
8966
+ const secretHeaderRefs = svc.secretHeaders !== void 0 && Object.keys(svc.secretHeaders).length > 0 ? svc.secretHeaders : void 0;
8967
+ let authHeaders;
8968
+ try {
8969
+ authHeaders = await resolveAuth({
8970
+ auth: svc.auth ?? { type: "none" },
8971
+ env: args.env,
8972
+ resolver: args.resolveAuth,
8973
+ ctx: {
8974
+ serviceName: svc.name,
8975
+ method: input.method,
8976
+ path: input.path,
8977
+ ...secretHeaderRefs !== void 0 ? { secretHeaderRefs } : {}
8978
+ },
8979
+ forceResolve: secretHeaderRefs !== void 0
8980
+ });
8981
+ } catch (err) {
8982
+ const raw = err instanceof Error ? err.message : String(err);
8983
+ const truncated = raw.length > 200 ? raw.slice(0, 200) + "\u2026" : raw;
8984
+ return errResult(`ERR_API_RESOLVER_FAILED: ${truncated}`);
8985
+ }
8986
+ const userHeaders = sanitizeHeaders(input.headers ?? {}, authHeaders);
8987
+ const aggregated = [];
8988
+ let pagesFetched = 0;
8989
+ let stoppedBy = "unknown";
8990
+ let lastStatus = 0;
8991
+ let nextOffset = 0;
8992
+ let nextPage = p.request?.firstPage ?? 1;
8993
+ let nextCursor = null;
8994
+ let nextLink = null;
8995
+ while (pagesFetched < maxPages && aggregated.length < maxItems) {
8996
+ const pageQuery = { ...input.query ?? {} };
8997
+ let pageUrl = null;
8998
+ if (p.mode === "offset") {
8999
+ const limit = p.request?.maxLimit ?? 100;
9000
+ if (p.request?.limitParam) pageQuery[p.request.limitParam] = String(limit);
9001
+ if (p.request?.offsetParam) pageQuery[p.request.offsetParam] = String(nextOffset);
9002
+ } else if (p.mode === "page") {
9003
+ if (p.request?.maxLimit !== void 0 && p.request?.limitParam) {
9004
+ pageQuery[p.request.limitParam] = String(p.request.maxLimit);
9005
+ }
9006
+ if (p.request?.pageParam) pageQuery[p.request.pageParam] = String(nextPage);
9007
+ } else if (p.mode === "cursor") {
9008
+ if (p.request?.maxLimit !== void 0 && p.request?.limitParam) {
9009
+ pageQuery[p.request.limitParam] = String(p.request.maxLimit);
9010
+ }
9011
+ if (nextCursor !== null && p.request?.cursorParam) {
9012
+ pageQuery[p.request.cursorParam] = nextCursor;
9013
+ }
9014
+ } else if (p.mode === "link-header") {
9015
+ if (nextLink !== null) pageUrl = nextLink;
9016
+ }
9017
+ const url = pageUrl ?? buildUrl(svc.baseUrl, input.path, pageQuery);
9018
+ await invokeHook(args.onRequest, {
9019
+ service: svc.name,
9020
+ method: input.method,
9021
+ path: input.path
9022
+ });
9023
+ const started = Date.now();
9024
+ let res;
9025
+ try {
9026
+ res = await fetchFn(url, {
9027
+ method: input.method,
9028
+ headers: {
9029
+ ...svc.defaultHeaders ?? {},
9030
+ ...userHeaders,
9031
+ ...authHeaders
9032
+ }
9033
+ });
9034
+ } catch (err) {
9035
+ const msg = err instanceof Error ? err.message : String(err);
9036
+ return paginationErr({
9037
+ code: "ERR_API_PAGINATION_PAGE_FAILED",
9038
+ message: `${input.method} ${input.path} page ${pagesFetched + 1} network error: ${msg}`,
9039
+ pagesFetched,
9040
+ itemsFetched: aggregated.length,
9041
+ partialItems: aggregated
9042
+ });
9043
+ }
9044
+ lastStatus = res.status;
9045
+ const raw = await res.text();
9046
+ const captured = raw.length > maxResponseBytes ? raw.slice(0, maxResponseBytes) + "\n\u2026[TRUNCATED]" : raw;
9047
+ await invokeHook(args.onResponse, {
9048
+ service: svc.name,
9049
+ method: input.method,
9050
+ path: input.path,
9051
+ status: res.status,
9052
+ latencyMs: Date.now() - started,
9053
+ bytesIn: raw.length
9054
+ });
9055
+ if (!res.ok) {
9056
+ return paginationErr({
9057
+ code: "ERR_API_PAGINATION_PAGE_FAILED",
9058
+ message: `${input.method} ${input.path} page ${pagesFetched + 1} returned status ${String(res.status)}`,
9059
+ pagesFetched,
9060
+ itemsFetched: aggregated.length,
9061
+ partialItems: aggregated,
9062
+ responsePreview: captured
9063
+ });
9064
+ }
9065
+ let body;
9066
+ try {
9067
+ body = JSON.parse(captured);
9068
+ } catch (err) {
9069
+ const msg = err instanceof Error ? err.message : String(err);
9070
+ return paginationErr({
9071
+ code: "ERR_API_PAGINATION_PAGE_FAILED",
9072
+ message: `${input.method} ${input.path} page ${pagesFetched + 1} returned non-JSON: ${msg}`,
9073
+ pagesFetched,
9074
+ itemsFetched: aggregated.length,
9075
+ partialItems: aggregated
9076
+ });
9077
+ }
9078
+ const itemsRaw = extractByDotPath(body, itemsPath);
9079
+ const items = asItemsArray(itemsRaw);
9080
+ if (items === null) {
9081
+ return errResult(
9082
+ `ERR_API_ITEMS_PATH_INVALID: ${input.method} ${input.path} response.${itemsPath} did not resolve to an array`
9083
+ );
9084
+ }
9085
+ pagesFetched += 1;
9086
+ for (const it of items) {
9087
+ if (aggregated.length >= maxItems) break;
9088
+ aggregated.push(it);
9089
+ }
9090
+ const stop = p.stop?.strategy;
9091
+ if (stop === "empty-page" && items.length === 0) {
9092
+ stoppedBy = "empty-page";
9093
+ break;
9094
+ }
9095
+ if (stop === "total-reached" && p.response?.totalPath !== void 0) {
9096
+ const total = asTotalCount(extractByDotPath(body, p.response.totalPath));
9097
+ if (total !== null && aggregated.length >= total) {
9098
+ stoppedBy = "total-reached";
9099
+ break;
9100
+ }
9101
+ }
9102
+ if (p.mode === "cursor") {
9103
+ const cursorRaw = p.response?.nextCursorPath ? extractByDotPath(body, p.response.nextCursorPath) : void 0;
9104
+ const cursor = typeof cursorRaw === "string" && cursorRaw.length > 0 ? cursorRaw : null;
9105
+ if (cursor === null) {
9106
+ stoppedBy = "missing-cursor";
9107
+ break;
9108
+ }
9109
+ nextCursor = cursor;
9110
+ } else if (p.mode === "link-header") {
9111
+ const linkRaw = p.response?.nextLinkPath ? extractByDotPath(body, p.response.nextLinkPath) : void 0;
9112
+ const link = typeof linkRaw === "string" && linkRaw.length > 0 ? linkRaw : null;
9113
+ if (link === null) {
9114
+ stoppedBy = "missing-next-link";
9115
+ break;
9116
+ }
9117
+ nextLink = link;
9118
+ } else if (p.mode === "offset") {
9119
+ if (items.length === 0) {
9120
+ stoppedBy = "empty-page-fallback";
9121
+ break;
9122
+ }
9123
+ nextOffset += items.length;
9124
+ } else if (p.mode === "page") {
9125
+ if (items.length === 0) {
9126
+ stoppedBy = "empty-page-fallback";
9127
+ break;
9128
+ }
9129
+ nextPage += 1;
9130
+ }
9131
+ }
9132
+ if (stoppedBy === "unknown") {
9133
+ if (aggregated.length >= maxItems) stoppedBy = "max-items";
9134
+ else if (pagesFetched >= maxPages) stoppedBy = "max-pages";
9135
+ }
9136
+ const payload = {
9137
+ ok: true,
9138
+ status: lastStatus || 200,
9139
+ items: aggregated,
9140
+ pagination: {
9141
+ mode: p.mode,
9142
+ pagesFetched,
9143
+ itemsFetched: aggregated.length,
9144
+ stoppedBy
9145
+ }
9146
+ };
9147
+ return {
9148
+ content: JSON.stringify(payload),
9149
+ metadata: {
9150
+ status: lastStatus || 200,
9151
+ service: svc.name,
9152
+ pagesFetched,
9153
+ itemsFetched: aggregated.length,
9154
+ stoppedBy
9155
+ }
9156
+ };
9157
+ }
9158
+ function paginationErr(args) {
9159
+ const payload = {
9160
+ ok: false,
9161
+ error: { code: args.code, message: args.message },
9162
+ pagination: {
9163
+ pagesFetched: args.pagesFetched,
9164
+ itemsFetched: args.itemsFetched
9165
+ },
9166
+ partialItems: args.partialItems,
9167
+ ...args.responsePreview !== void 0 ? { responsePreview: args.responsePreview.slice(0, 4096) } : {}
9168
+ };
9169
+ return { content: JSON.stringify(payload), isError: true };
9170
+ }
8669
9171
 
8670
9172
  // src/tools/describeService.ts
8671
9173
  init_cjs_shims();
@@ -8678,7 +9180,12 @@ function describe(svc) {
8678
9180
  path: ep.path,
8679
9181
  description: ep.description,
8680
9182
  ...ep.inputSchema !== void 0 ? { inputSchema: ep.inputSchema } : {},
8681
- ...ep.outputHint !== void 0 ? { outputHint: ep.outputHint } : {}
9183
+ ...ep.outputHint !== void 0 ? { outputHint: ep.outputHint } : {},
9184
+ // Plan 050 — surface pagination + response extraction metadata
9185
+ // so the model knows it can request `pagination.auto: true` and
9186
+ // sees the declared items / total / cursor paths inline.
9187
+ ...ep.pagination !== void 0 ? { pagination: ep.pagination } : {},
9188
+ ...ep.response !== void 0 ? { response: ep.response } : {}
8682
9189
  })
8683
9190
  );
8684
9191
  return {
@@ -8705,7 +9212,7 @@ function createDescribeServiceTool(opts) {
8705
9212
  const inputSchema19 = import_zod24.z.object({
8706
9213
  service: import_zod24.z.enum(serviceNames)
8707
9214
  });
8708
- const description = `Look up the endpoint catalog for one configured API service. Returns every endpoint's method, path, description, and input schema. Call this before invoking \`ApiCall\` when the service has lazy endpoints. Services: ${serviceNames.join(", ")}.`;
9215
+ const description = `Look up the endpoint catalog for one configured API service. Returns every endpoint's method, path, description, input schema, and (when declared) pagination + response-extraction metadata. Call this before invoking \`ApiCall\` when the service has lazy endpoints; for paginated list endpoints, prefer \`ApiCall\` with \`pagination.auto: true\` over manually issuing one call per page. Services: ${serviceNames.join(", ")}.`;
8709
9216
  return defineTool({
8710
9217
  name: opts.toolName ?? "DescribeService",
8711
9218
  description,