@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.
- package/dist/index.mjs +205 -15
- 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)
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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("");
|