@skydiveai/pi-extensions 0.1.0-beta.1965 → 0.1.0-beta.1968

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 (2) hide show
  1. package/dist/index.mjs +205 -15
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1069,6 +1069,126 @@ async function connectClient(id, config, opts = {}) {
1069
1069
  };
1070
1070
  }
1071
1071
  //#endregion
1072
+ //#region src/extensions/mcp/limits.ts
1073
+ /**
1074
+ * Tool-budget guardrails for MCP registration.
1075
+ *
1076
+ * Why this exists: the agent loop sends the *full* tool list — every tool's
1077
+ * name, description, and JSON-schema `parameters` — to the model on every
1078
+ * prompt. MCP servers add tools without bound: a handful of chatty servers (or
1079
+ * one server that exposes 100+ tools, or a few tools with enormous schemas) can
1080
+ * push the registered set past what the model can accept, and the request is
1081
+ * rejected before the turn even runs. Because `reconcile` re-registers from
1082
+ * `mcp.config.json` on *every* turn, an over-limit config bricks the harness on
1083
+ * a loop — the agent can't get a turn to run in order to edit the config back
1084
+ * down. Worse, the person can't tell *why*: tools just stop working.
1085
+ *
1086
+ * What actually overflows the request is *tokens*, not tool count — a few tools
1087
+ * with deeply-nested schemas and long descriptions cost more than a hundred
1088
+ * trivial ones. And how many tokens are safe depends on the *model*: a 200K
1089
+ * context window can afford far more tool surface than a 32K one. So the primary
1090
+ * limiter is a **token budget derived from the active model's context window**,
1091
+ * with a fixed tool-count cap as a coarse secondary guard (and the fallback
1092
+ * when the model — hence its window — isn't known at reconcile time).
1093
+ *
1094
+ * The fix is to make registration bounded and fail-soft. We register in
1095
+ * deterministic config order and stop before we blow the budget, recording how
1096
+ * much we dropped so the agent is *told* it hit the limit and which servers
1097
+ * were truncated. A config that would have bricked the harness now degrades to
1098
+ * "a bounded set of tools plus a loud warning", which the agent can act on by
1099
+ * pruning servers.
1100
+ *
1101
+ * Everything is env-overridable so the ceilings can be tuned per deployment
1102
+ * without a release, but ships with conservative defaults. A cap value of 0 (or
1103
+ * a non-finite / negative override) disables that cap — an explicit escape
1104
+ * hatch, not the default.
1105
+ */
1106
+ const env = process.env;
1107
+ /**
1108
+ * Fraction of the model's context window we're willing to spend on MCP tool
1109
+ * schemas. Tool definitions are sent on every prompt, so they permanently eat
1110
+ * into the window available for the conversation — a small slice is plenty for
1111
+ * a healthy tool set while leaving the vast majority for actual work. 0.15 of a
1112
+ * 200K window is ~30K tokens of tool schema, comfortably more than any sane
1113
+ * MCP setup; of a 32K window it's ~4.8K, which correctly forces truncation
1114
+ * before a small model chokes.
1115
+ */
1116
+ const DEFAULT_MCP_TOOL_TOKEN_BUDGET_FRACTION = .15;
1117
+ /**
1118
+ * Floor for the token budget when the model's context window is unknown at
1119
+ * reconcile time (e.g. the model hasn't been resolved yet). Generous enough not
1120
+ * to truncate an ordinary tool set, low enough to still catch a runaway.
1121
+ */
1122
+ const DEFAULT_MCP_TOOL_TOKEN_BUDGET_FLOOR = 16e3;
1123
+ /**
1124
+ * Read a positive-integer cap from an env var, falling back to `fallback`.
1125
+ * A `0` override (or any non-finite / negative value) means "no cap" and is
1126
+ * returned as `Infinity`, so callers can compare against it directly.
1127
+ */
1128
+ function readCap(raw, fallback) {
1129
+ if (raw === void 0 || raw.trim() === "") return fallback;
1130
+ const parsed = Number(raw);
1131
+ if (!Number.isFinite(parsed) || parsed < 0) return Number.POSITIVE_INFINITY;
1132
+ if (parsed === 0) return Number.POSITIVE_INFINITY;
1133
+ return Math.floor(parsed);
1134
+ }
1135
+ /** Read a fraction in (0, 1] from an env var, falling back to `fallback`. */
1136
+ function readFraction(raw, fallback) {
1137
+ if (raw === void 0 || raw.trim() === "") return fallback;
1138
+ const parsed = Number(raw);
1139
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) return fallback;
1140
+ return parsed;
1141
+ }
1142
+ /** Read a non-negative integer from an env var, falling back to `fallback`. */
1143
+ function readNonNegativeInt(raw, fallback) {
1144
+ if (raw === void 0 || raw.trim() === "") return fallback;
1145
+ const parsed = Number(raw);
1146
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback;
1147
+ return Math.floor(parsed);
1148
+ }
1149
+ /**
1150
+ * Resolve the active caps from the environment. Read once per reconcile so a
1151
+ * deployment can retune without a restart, cheap enough not to cache.
1152
+ */
1153
+ function resolveMcpToolLimits() {
1154
+ return {
1155
+ maxTotalTools: readCap(env["SKYDIVE_MCP_MAX_TOTAL_TOOLS"], 128),
1156
+ maxToolsPerServer: readCap(env["SKYDIVE_MCP_MAX_TOOLS_PER_SERVER"], 50),
1157
+ tokenBudgetFraction: readFraction(env["SKYDIVE_MCP_TOOL_TOKEN_BUDGET_FRACTION"], DEFAULT_MCP_TOOL_TOKEN_BUDGET_FRACTION),
1158
+ tokenBudgetFloor: readNonNegativeInt(env["SKYDIVE_MCP_TOOL_TOKEN_BUDGET_FLOOR"], DEFAULT_MCP_TOOL_TOKEN_BUDGET_FLOOR)
1159
+ };
1160
+ }
1161
+ /**
1162
+ * The token budget for MCP tool schemas, given the active model's context
1163
+ * window (or undefined when the model isn't known yet).
1164
+ *
1165
+ * With a known window we spend `tokenBudgetFraction` of it, but never less than
1166
+ * the floor — a tiny window shouldn't collapse the budget to near-zero and
1167
+ * strand every tool. With no window we fall back to the floor outright.
1168
+ */
1169
+ function resolveTokenBudget(limits, contextWindow) {
1170
+ if (contextWindow === void 0 || !Number.isFinite(contextWindow)) return limits.tokenBudgetFloor;
1171
+ return Math.max(limits.tokenBudgetFloor, Math.floor(contextWindow * limits.tokenBudgetFraction));
1172
+ }
1173
+ /**
1174
+ * Estimate the tokens an MCP tool's *definition* costs in the request. We
1175
+ * serialize what's actually sent to the model — the tool name, its description,
1176
+ * and its JSON-schema parameters — and apply pi's own ~chars/4 heuristic
1177
+ * (`estimateTokens` in the coding agent uses the same convention; there is no
1178
+ * per-provider tokenizer to lean on, and the provider's real usage is only
1179
+ * known after the response). Rough by design, but it tracks the true cost far
1180
+ * better than a flat per-tool count: a fat schema is charged for its fatness.
1181
+ */
1182
+ function estimateToolTokens(tool) {
1183
+ let chars = tool.name.length + (tool.description?.length ?? 0);
1184
+ if (tool.inputSchema !== void 0 && tool.inputSchema !== null) try {
1185
+ chars += JSON.stringify(tool.inputSchema).length;
1186
+ } catch {
1187
+ chars += 256;
1188
+ }
1189
+ return Math.ceil(chars / 4);
1190
+ }
1191
+ //#endregion
1072
1192
  //#region src/extensions/mcp/mcp-config.ts
1073
1193
  /**
1074
1194
  * mcp.config.json schema + loader. Split out from the extension so it has
@@ -1275,19 +1395,26 @@ var McpExtension = class {
1275
1395
  }));
1276
1396
  this.registeredMcpToolNames.add(name);
1277
1397
  }
1278
- async reconcile({ pi, configPath, connectTimeoutMs }) {
1398
+ async reconcile({ pi, configPath, connectTimeoutMs, contextWindow }) {
1279
1399
  let config;
1280
1400
  try {
1281
1401
  config = await loadMcpConfig(configPath);
1282
1402
  } catch (err) {
1283
1403
  throw new Error(`Failed to load MCP config: ${err instanceof Error ? err.message : String(err)}`);
1284
1404
  }
1405
+ const limits = resolveMcpToolLimits();
1406
+ const tokenBudget = resolveTokenBudget(limits, contextWindow);
1285
1407
  const summary = {
1286
1408
  added: [],
1287
1409
  removed: [],
1288
1410
  refreshed: [],
1289
1411
  errors: [],
1290
1412
  totalTools: 0,
1413
+ droppedTools: 0,
1414
+ limits,
1415
+ tokenBudget,
1416
+ tokensUsed: 0,
1417
+ toolCounts: {},
1291
1418
  servers: {}
1292
1419
  };
1293
1420
  const desiredIds = new Set(Object.keys(config.servers));
@@ -1311,14 +1438,46 @@ var McpExtension = class {
1311
1438
  if (outcome.error) summary.errors.push(outcome.error);
1312
1439
  if (outcome.change === "added") summary.added.push(id);
1313
1440
  else if (outcome.change === "refreshed") summary.refreshed.push(id);
1314
- if (outcome.tools) for (const tool of outcome.tools.list) {
1315
- this.registerMcpTool({
1316
- pi,
1317
- serverId: id,
1318
- client: outcome.tools.client,
1319
- tool
1320
- });
1321
- summary.totalTools++;
1441
+ if (outcome.tools) {
1442
+ const advertised = outcome.tools.list.length;
1443
+ const perServerRoom = Math.min(advertised, limits.maxToolsPerServer);
1444
+ let registered = 0;
1445
+ let serverTokens = 0;
1446
+ let droppedReason = null;
1447
+ for (const tool of outcome.tools.list) {
1448
+ if (summary.totalTools >= limits.maxTotalTools) {
1449
+ droppedReason = "total";
1450
+ break;
1451
+ }
1452
+ if (registered >= perServerRoom) {
1453
+ droppedReason = "per_server";
1454
+ break;
1455
+ }
1456
+ const cost = estimateToolTokens(tool);
1457
+ if (summary.tokensUsed + cost > tokenBudget && summary.totalTools > 0) {
1458
+ droppedReason = "tokens";
1459
+ break;
1460
+ }
1461
+ this.registerMcpTool({
1462
+ pi,
1463
+ serverId: id,
1464
+ client: outcome.tools.client,
1465
+ tool
1466
+ });
1467
+ registered++;
1468
+ serverTokens += cost;
1469
+ summary.totalTools++;
1470
+ summary.tokensUsed += cost;
1471
+ }
1472
+ const dropped = advertised - registered;
1473
+ if (dropped > 0) summary.droppedTools += dropped;
1474
+ else droppedReason = null;
1475
+ summary.toolCounts[id] = {
1476
+ advertised,
1477
+ registered,
1478
+ tokens: serverTokens,
1479
+ droppedReason
1480
+ };
1322
1481
  }
1323
1482
  }
1324
1483
  return summary;
@@ -1563,10 +1722,11 @@ var McpExtension = class {
1563
1722
  }
1564
1723
  };
1565
1724
  }
1566
- async reconcileAndRecordMtime({ pi, configPath, reason }) {
1725
+ async reconcileAndRecordMtime({ pi, configPath, reason, contextWindow }) {
1567
1726
  const summary = await this.reconcile({
1568
1727
  pi,
1569
- configPath
1728
+ configPath,
1729
+ contextWindow
1570
1730
  });
1571
1731
  this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1572
1732
  if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
@@ -1574,6 +1734,9 @@ var McpExtension = class {
1574
1734
  event: "mcp_reconcile",
1575
1735
  reason,
1576
1736
  total_tools: summary.totalTools,
1737
+ tokens_used: summary.tokensUsed,
1738
+ token_budget: summary.tokenBudget,
1739
+ dropped_tools: summary.droppedTools,
1577
1740
  added: summary.added,
1578
1741
  removed: summary.removed,
1579
1742
  refreshed: summary.refreshed,
@@ -1590,7 +1753,8 @@ var McpExtension = class {
1590
1753
  await this.reconcileAndRecordMtime({
1591
1754
  pi,
1592
1755
  configPath,
1593
- reason: "session_start"
1756
+ reason: "session_start",
1757
+ contextWindow: ctx.model?.contextWindow
1594
1758
  });
1595
1759
  } catch (err) {
1596
1760
  log$12.error({
@@ -1616,7 +1780,8 @@ var McpExtension = class {
1616
1780
  await this.reconcileAndRecordMtime({
1617
1781
  pi,
1618
1782
  configPath,
1619
- reason: "auto_reload"
1783
+ reason: "auto_reload",
1784
+ contextWindow: ctx.model?.contextWindow
1620
1785
  });
1621
1786
  } catch (err) {
1622
1787
  log$12.error({
@@ -1636,7 +1801,8 @@ var McpExtension = class {
1636
1801
  const summary = await this.reconcileAndRecordMtime({
1637
1802
  pi,
1638
1803
  configPath,
1639
- reason: "tool"
1804
+ reason: "tool",
1805
+ contextWindow: ctx.model?.contextWindow
1640
1806
  });
1641
1807
  return {
1642
1808
  content: [{
@@ -1695,9 +1861,29 @@ function appendStderrBlock(lines, stderr) {
1695
1861
  for (const line of stderr.trimEnd().split("\n")) lines.push(` ${line}`);
1696
1862
  lines.push(" ---");
1697
1863
  }
1864
+ /**
1865
+ * Human-readable lines describing any budget-driven truncation, shared by
1866
+ * `summaryText` (the reload_mcp tool output) and `formatMcpUpdateMessage` (the
1867
+ * synthetic continuation). Empty when nothing was dropped.
1868
+ */
1869
+ function truncationLines(summary) {
1870
+ if (summary.droppedTools <= 0) return [];
1871
+ const lines = [];
1872
+ const { maxTotalTools, maxToolsPerServer } = summary.limits;
1873
+ const totalCapHint = Number.isFinite(maxTotalTools) ? `${maxTotalTools}` : "unlimited";
1874
+ lines.push(` WARNING: ${summary.droppedTools} MCP tool(s) were NOT registered because a tool budget was hit (token budget: ~${summary.tokenBudget} tokens, used ~${summary.tokensUsed}; total-tool cap: ${totalCapHint}, per-server cap: ${Number.isFinite(maxToolsPerServer) ? maxToolsPerServer : "unlimited"}).`);
1875
+ lines.push(" Tool definitions are sent to the model on every prompt; too many (or too-large) tool schemas push the request over the limit, so they are budgeted against the model context window. Prune servers from mcp.config.json to bring the tool surface down.");
1876
+ for (const [id, count] of Object.entries(summary.toolCounts)) {
1877
+ if (count.droppedReason === null) continue;
1878
+ const why = count.droppedReason === "tokens" ? "token budget exhausted" : count.droppedReason === "total" ? "total-tool cap reached" : "per-server cap";
1879
+ lines.push(` - ${id}: registered ${count.registered}/${count.advertised} tools (~${count.tokens} tokens, ${why}).`);
1880
+ }
1881
+ return lines;
1882
+ }
1698
1883
  function summaryText(summary) {
1699
1884
  const lines = [];
1700
1885
  lines.push(`MCP reconcile complete: ${summary.totalTools} tool(s) live.`);
1886
+ lines.push(...truncationLines(summary));
1701
1887
  if (summary.added.length > 0) lines.push(` Added: ${summary.added.join(", ")}`);
1702
1888
  if (summary.refreshed.length > 0) lines.push(` Refreshed: ${summary.refreshed.join(", ")}`);
1703
1889
  if (summary.removed.length > 0) lines.push(` Removed: ${summary.removed.join(", ")}`);
@@ -1723,7 +1909,7 @@ function summaryText(summary) {
1723
1909
  return lines.join("\n");
1724
1910
  }
1725
1911
  function summaryHasChanges(summary) {
1726
- return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0;
1912
+ return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0 || summary.droppedTools > 0;
1727
1913
  }
1728
1914
  /**
1729
1915
  * Format a queued tool-update as a synthetic system-style message for
@@ -1736,6 +1922,10 @@ function formatMcpUpdateMessage(summary) {
1736
1922
  if (summary.added.length > 0) lines.push(`Newly available servers: ${summary.added.join(", ")}`);
1737
1923
  if (summary.refreshed.length > 0) lines.push(`Refreshed servers: ${summary.refreshed.join(", ")}`);
1738
1924
  if (summary.removed.length > 0) lines.push(`Removed servers (and their tools): ${summary.removed.join(", ")}`);
1925
+ if (summary.droppedTools > 0) {
1926
+ lines.push("");
1927
+ lines.push(...truncationLines(summary));
1928
+ }
1739
1929
  const pending = pendingAuthEntries(summary);
1740
1930
  if (pending.length > 0) {
1741
1931
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.1965",
3
+ "version": "0.1.0-beta.1968",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",