@skydiveai/pi-extensions 0.1.0-beta.76 → 0.1.0-beta.763
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 +319 -109
- package/package.json +2 -7
package/dist/index.mjs
CHANGED
|
@@ -21,6 +21,10 @@ import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace
|
|
|
21
21
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
22
22
|
import { hc } from "hono/client";
|
|
23
23
|
import { parse } from "yaml";
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { availableParallelism } from "node:os";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { quote } from "shell-quote";
|
|
24
28
|
import { createWriteStream } from "node:fs";
|
|
25
29
|
import { finished } from "node:stream/promises";
|
|
26
30
|
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
@@ -255,7 +259,7 @@ function createHealthHandler({ metadata }) {
|
|
|
255
259
|
* read on the hot path before every LLM call), it falls back to the default
|
|
256
260
|
* for that knob and logs once.
|
|
257
261
|
*/
|
|
258
|
-
const log$
|
|
262
|
+
const log$14 = logger.child({ module: "context-management-config" });
|
|
259
263
|
const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
|
|
260
264
|
enabled: false,
|
|
261
265
|
perResultMaxBytes: 16 * 1024,
|
|
@@ -303,7 +307,7 @@ function resolveContextManagementConfig(env = process.env) {
|
|
|
303
307
|
maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
|
|
304
308
|
});
|
|
305
309
|
if (!parsed.success) {
|
|
306
|
-
log$
|
|
310
|
+
log$14.warn({
|
|
307
311
|
event: "context_management_config_invalid",
|
|
308
312
|
err: parsed.error
|
|
309
313
|
}, "falling back to default context-management config");
|
|
@@ -437,7 +441,7 @@ const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task i
|
|
|
437
441
|
* or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
|
|
438
442
|
* `tools/_example.ts` documents the shape without registering.
|
|
439
443
|
*/
|
|
440
|
-
const log$
|
|
444
|
+
const log$13 = logger.child({ module: "local-tools-extension" });
|
|
441
445
|
const TOOLS_DIRNAME = "tools";
|
|
442
446
|
const fileState = /* @__PURE__ */ new Map();
|
|
443
447
|
let pendingLocalToolsUpdate = null;
|
|
@@ -581,7 +585,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
|
|
|
581
585
|
dir
|
|
582
586
|
});
|
|
583
587
|
if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
|
|
584
|
-
log$
|
|
588
|
+
log$13.info({
|
|
585
589
|
event: "local_tools_reconcile",
|
|
586
590
|
reason,
|
|
587
591
|
total_tools: summary.totalTools,
|
|
@@ -603,7 +607,7 @@ const localToolsExtension = (pi) => {
|
|
|
603
607
|
reason: "session_start"
|
|
604
608
|
});
|
|
605
609
|
} catch (err) {
|
|
606
|
-
log$
|
|
610
|
+
log$13.error({
|
|
607
611
|
err,
|
|
608
612
|
event: "local_tools_reconcile_failed"
|
|
609
613
|
}, "local tools reconcile failed");
|
|
@@ -615,7 +619,7 @@ const localToolsExtension = (pi) => {
|
|
|
615
619
|
try {
|
|
616
620
|
current = await listToolFiles(dir);
|
|
617
621
|
} catch (err) {
|
|
618
|
-
log$
|
|
622
|
+
log$13.warn({
|
|
619
623
|
err,
|
|
620
624
|
event: "local_tools_listing_failed"
|
|
621
625
|
}, "tools/ listing failed");
|
|
@@ -637,7 +641,7 @@ const localToolsExtension = (pi) => {
|
|
|
637
641
|
reason: "auto_reload"
|
|
638
642
|
});
|
|
639
643
|
} catch (err) {
|
|
640
|
-
log$
|
|
644
|
+
log$13.error({
|
|
641
645
|
err,
|
|
642
646
|
event: "local_tools_auto_reload_failed"
|
|
643
647
|
}, "auto-reload after tools/ change failed");
|
|
@@ -862,7 +866,7 @@ async function loadMcpConfig(path) {
|
|
|
862
866
|
* Clients are keyed by JSON-stringified config and reused across
|
|
863
867
|
* reloads — only changed configs reconnect.
|
|
864
868
|
*/
|
|
865
|
-
const log$
|
|
869
|
+
const log$12 = logger.child({ module: "mcp-extension" });
|
|
866
870
|
async function closeConnected(connected) {
|
|
867
871
|
try {
|
|
868
872
|
await connected.client.close();
|
|
@@ -1226,7 +1230,7 @@ var McpExtension = class {
|
|
|
1226
1230
|
});
|
|
1227
1231
|
this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
|
|
1228
1232
|
if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
|
|
1229
|
-
log$
|
|
1233
|
+
log$12.info({
|
|
1230
1234
|
event: "mcp_reconcile",
|
|
1231
1235
|
reason,
|
|
1232
1236
|
total_tools: summary.totalTools,
|
|
@@ -1249,7 +1253,7 @@ var McpExtension = class {
|
|
|
1249
1253
|
reason: "session_start"
|
|
1250
1254
|
});
|
|
1251
1255
|
} catch (err) {
|
|
1252
|
-
log$
|
|
1256
|
+
log$12.error({
|
|
1253
1257
|
err,
|
|
1254
1258
|
event: "mcp_reconcile_failed"
|
|
1255
1259
|
}, "MCP reconcile failed");
|
|
@@ -1261,7 +1265,7 @@ var McpExtension = class {
|
|
|
1261
1265
|
try {
|
|
1262
1266
|
mtime = await readConfigMtimeMs(configPath);
|
|
1263
1267
|
} catch (err) {
|
|
1264
|
-
log$
|
|
1268
|
+
log$12.warn({
|
|
1265
1269
|
err,
|
|
1266
1270
|
event: "mcp_mtime_check_failed"
|
|
1267
1271
|
}, "mtime check on mcp.config.json failed");
|
|
@@ -1275,7 +1279,7 @@ var McpExtension = class {
|
|
|
1275
1279
|
reason: "auto_reload"
|
|
1276
1280
|
});
|
|
1277
1281
|
} catch (err) {
|
|
1278
|
-
log$
|
|
1282
|
+
log$12.error({
|
|
1279
1283
|
err,
|
|
1280
1284
|
event: "mcp_auto_reload_failed"
|
|
1281
1285
|
}, "auto-reload after mcp.config.json change failed");
|
|
@@ -1683,15 +1687,15 @@ const HEARTBEAT_THROTTLE_MS = 6e4;
|
|
|
1683
1687
|
const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1684
1688
|
const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
|
|
1685
1689
|
const DAEMON_URL = "http://localhost:38994";
|
|
1686
|
-
const log$
|
|
1690
|
+
const log$11 = logger.child({ module: "platform-ext" });
|
|
1687
1691
|
function sandboxClient() {
|
|
1688
1692
|
const apiUrl = apiBaseUrl();
|
|
1689
1693
|
if (!apiUrl) return null;
|
|
1690
1694
|
return hc(`${apiUrl}/api/v1/sandbox`);
|
|
1691
1695
|
}
|
|
1692
1696
|
/**
|
|
1693
|
-
* Fetch every harness feature flag in one GET (`{ contextManagement,
|
|
1694
|
-
*
|
|
1697
|
+
* Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
|
|
1698
|
+
* — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
|
|
1695
1699
|
* null when indeterminate (no api url, or the request failed) so the shared
|
|
1696
1700
|
* poller keeps the last-known values rather than flipping on a transient error.
|
|
1697
1701
|
* This is the single fetch behind `feature-flags-poll.ts`; extensions read the
|
|
@@ -1703,19 +1707,15 @@ async function fetchHarnessFlags() {
|
|
|
1703
1707
|
try {
|
|
1704
1708
|
const res = await client["feature-flags"].$get();
|
|
1705
1709
|
if (!res.ok) {
|
|
1706
|
-
log$
|
|
1710
|
+
log$11.debug({
|
|
1707
1711
|
status: res.status,
|
|
1708
1712
|
event: "feature_flags_fetch_failed"
|
|
1709
1713
|
}, "feature-flags fetch failed");
|
|
1710
1714
|
return null;
|
|
1711
1715
|
}
|
|
1712
|
-
|
|
1713
|
-
return {
|
|
1714
|
-
contextManagement: body.contextManagement ?? null,
|
|
1715
|
-
subagent: body.subagent ?? null
|
|
1716
|
-
};
|
|
1716
|
+
return { contextManagement: (await res.json()).contextManagement ?? null };
|
|
1717
1717
|
} catch (err) {
|
|
1718
|
-
log$
|
|
1718
|
+
log$11.debug({
|
|
1719
1719
|
err,
|
|
1720
1720
|
event: "feature_flags_fetch_error"
|
|
1721
1721
|
}, "feature-flags request errored");
|
|
@@ -1726,7 +1726,7 @@ function postHeartbeat({ messageId }) {
|
|
|
1726
1726
|
const client = sandboxClient();
|
|
1727
1727
|
if (!client) return;
|
|
1728
1728
|
client.heartbeat.$post({ json: { messageId } }).catch((err) => {
|
|
1729
|
-
log$
|
|
1729
|
+
log$11.debug({
|
|
1730
1730
|
err,
|
|
1731
1731
|
event: "heartbeat_failed"
|
|
1732
1732
|
}, "heartbeat failed");
|
|
@@ -1738,7 +1738,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1738
1738
|
try {
|
|
1739
1739
|
const res = await client["message-conversation"].$get({ query: { messageId } });
|
|
1740
1740
|
if (!res.ok) {
|
|
1741
|
-
log$
|
|
1741
|
+
log$11.warn({
|
|
1742
1742
|
status: res.status,
|
|
1743
1743
|
messageId,
|
|
1744
1744
|
event: "resolve_conversation_failed"
|
|
@@ -1747,7 +1747,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1747
1747
|
}
|
|
1748
1748
|
return (await res.json()).conversationId ?? null;
|
|
1749
1749
|
} catch (err) {
|
|
1750
|
-
log$
|
|
1750
|
+
log$11.warn({
|
|
1751
1751
|
err,
|
|
1752
1752
|
messageId,
|
|
1753
1753
|
event: "resolve_conversation_error"
|
|
@@ -1772,7 +1772,11 @@ async function postSubagentSpawn({ messageId, tasks }) {
|
|
|
1772
1772
|
tasks
|
|
1773
1773
|
} });
|
|
1774
1774
|
if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
|
|
1775
|
-
|
|
1775
|
+
const body = await res.json();
|
|
1776
|
+
return {
|
|
1777
|
+
taskIds: body.taskIds,
|
|
1778
|
+
tasks: body.tasks ?? []
|
|
1779
|
+
};
|
|
1776
1780
|
}
|
|
1777
1781
|
function createHeartbeatThrottle({ messageId }) {
|
|
1778
1782
|
let lastAt = 0;
|
|
@@ -1821,7 +1825,7 @@ function createToolHeartbeat({ messageId }) {
|
|
|
1821
1825
|
}
|
|
1822
1826
|
heartbeatCount++;
|
|
1823
1827
|
if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
|
|
1824
|
-
log$
|
|
1828
|
+
log$11.warn({
|
|
1825
1829
|
heartbeatCount,
|
|
1826
1830
|
activeToolCalls: [...activeToolCalls]
|
|
1827
1831
|
}, "tool heartbeat max reached, stopping");
|
|
@@ -1852,7 +1856,7 @@ function postToDaemon(path, body) {
|
|
|
1852
1856
|
headers: { "content-type": "application/json" },
|
|
1853
1857
|
body: JSON.stringify(body)
|
|
1854
1858
|
}).catch((err) => {
|
|
1855
|
-
log$
|
|
1859
|
+
log$11.debug({
|
|
1856
1860
|
err,
|
|
1857
1861
|
path,
|
|
1858
1862
|
event: "daemon_post_failed"
|
|
@@ -1861,7 +1865,7 @@ function postToDaemon(path, body) {
|
|
|
1861
1865
|
}
|
|
1862
1866
|
function createPlatformExtensions({ sessionId, channelContext }) {
|
|
1863
1867
|
return (pi) => {
|
|
1864
|
-
log$
|
|
1868
|
+
log$11.info({
|
|
1865
1869
|
sessionId,
|
|
1866
1870
|
hasChannelContext: Boolean(channelContext)
|
|
1867
1871
|
}, "platform extension initialized");
|
|
@@ -1910,7 +1914,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1910
1914
|
});
|
|
1911
1915
|
});
|
|
1912
1916
|
pi.on("agent_end", () => {
|
|
1913
|
-
log$
|
|
1917
|
+
log$11.info({ sessionId }, "session ending");
|
|
1914
1918
|
postToDaemon("/session/end", { sessionId });
|
|
1915
1919
|
});
|
|
1916
1920
|
};
|
|
@@ -1921,37 +1925,29 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1921
1925
|
* Shared harness feature-flag poll.
|
|
1922
1926
|
*
|
|
1923
1927
|
* The api exposes one `/feature-flags` GET that returns every harness flag in a
|
|
1924
|
-
* single response (`{ contextManagement,
|
|
1928
|
+
* single response (`{ contextManagement, commandFlags }` — see
|
|
1925
1929
|
* apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
|
|
1926
1930
|
* extension issuing its own GET — and, worse, a *blocking* GET on the
|
|
1927
1931
|
* pre-first-token `session_start` path — a single background poller fetches
|
|
1928
1932
|
* that response once per interval and fans the values out to every subscriber.
|
|
1929
1933
|
*
|
|
1930
|
-
* Why one poller:
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
* every session, flag on or off. Reading the last-polled value instead keeps
|
|
1935
|
-
* the hot path allocation-only. A cold cache reads as `null` (fail-open to
|
|
1936
|
-
* unregistered); a newly-flipped flag takes effect on the next poll, matching
|
|
1937
|
-
* how context-management already treats its flag.
|
|
1934
|
+
* Why one poller: context-management consumes the `contextManagement` flag
|
|
1935
|
+
* without a blocking GET on the pre-first-token `session_start` path. Reading
|
|
1936
|
+
* the last-polled value keeps the hot path allocation-only; a cold cache reads
|
|
1937
|
+
* as `null` and a newly-flipped flag takes effect on the next poll.
|
|
1938
1938
|
*
|
|
1939
1939
|
* The poll is fire-and-forget and self-unref'd — it never keeps the process
|
|
1940
1940
|
* alive and an indeterminate result (no api url / transient failure) leaves the
|
|
1941
1941
|
* last-known values untouched so a blip can't silently flip behavior.
|
|
1942
1942
|
*/
|
|
1943
|
-
const log$
|
|
1943
|
+
const log$10 = logger.child({ module: "feature-flags-poll" });
|
|
1944
1944
|
const FLAG_POLL_INTERVAL_MS = 6e4;
|
|
1945
1945
|
let contextManagement = null;
|
|
1946
|
-
|
|
1947
|
-
const subscribers = {
|
|
1948
|
-
contextManagement: /* @__PURE__ */ new Set(),
|
|
1949
|
-
subagent: /* @__PURE__ */ new Set()
|
|
1950
|
-
};
|
|
1946
|
+
const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
|
|
1951
1947
|
let pollerStarted = false;
|
|
1952
1948
|
let firstPollSettled = false;
|
|
1953
1949
|
let resolveFirstPoll = null;
|
|
1954
|
-
|
|
1950
|
+
new Promise((resolve) => {
|
|
1955
1951
|
resolveFirstPoll = resolve;
|
|
1956
1952
|
});
|
|
1957
1953
|
function markFirstPollSettled() {
|
|
@@ -1960,20 +1956,8 @@ function markFirstPollSettled() {
|
|
|
1960
1956
|
resolveFirstPoll?.();
|
|
1961
1957
|
}
|
|
1962
1958
|
/** Last-polled value of a flag, or `null` if not yet resolved. */
|
|
1963
|
-
function getPolledFlag(
|
|
1964
|
-
return
|
|
1965
|
-
}
|
|
1966
|
-
/**
|
|
1967
|
-
* Await the first poll already kicked by `startFeatureFlagPoller` (never a new
|
|
1968
|
-
* GET). Resolves when that poll settles, immediately if it already has, or
|
|
1969
|
-
* immediately when there's no flag source to poll. Callers on the hot path
|
|
1970
|
-
* should race this against their own short timeout so a slow/failed flag
|
|
1971
|
-
* service cannot delay first-token; a timeout just means the caller reads the
|
|
1972
|
-
* still-cold cache and falls back to its default, exactly as before.
|
|
1973
|
-
*/
|
|
1974
|
-
function awaitFirstFlagPoll() {
|
|
1975
|
-
if (firstPollSettled || !hasFlagSource()) return Promise.resolve();
|
|
1976
|
-
return firstPollPromise;
|
|
1959
|
+
function getPolledFlag(_name) {
|
|
1960
|
+
return contextManagement;
|
|
1977
1961
|
}
|
|
1978
1962
|
/**
|
|
1979
1963
|
* Subscribe to changes of a flag. The callback fires only on a *transition*
|
|
@@ -1986,13 +1970,12 @@ function onFlagChange(name, cb) {
|
|
|
1986
1970
|
}
|
|
1987
1971
|
function apply(name, next) {
|
|
1988
1972
|
if (next === null) return;
|
|
1989
|
-
const prev =
|
|
1990
|
-
|
|
1991
|
-
else subagent = next;
|
|
1973
|
+
const prev = contextManagement;
|
|
1974
|
+
contextManagement = next;
|
|
1992
1975
|
if (next !== prev) for (const cb of subscribers[name]) try {
|
|
1993
1976
|
cb(next);
|
|
1994
1977
|
} catch (err) {
|
|
1995
|
-
log$
|
|
1978
|
+
log$10.warn({
|
|
1996
1979
|
err,
|
|
1997
1980
|
flag: name
|
|
1998
1981
|
}, "flag subscriber threw");
|
|
@@ -2003,9 +1986,8 @@ async function pollOnce() {
|
|
|
2003
1986
|
const flags = await fetchHarnessFlags();
|
|
2004
1987
|
if (!flags) return;
|
|
2005
1988
|
apply("contextManagement", flags.contextManagement ?? null);
|
|
2006
|
-
apply("subagent", flags.subagent ?? null);
|
|
2007
1989
|
} catch (err) {
|
|
2008
|
-
log$
|
|
1990
|
+
log$10.debug({ err }, "feature-flag poll threw");
|
|
2009
1991
|
}
|
|
2010
1992
|
}
|
|
2011
1993
|
/**
|
|
@@ -2112,7 +2094,7 @@ function transformContextMessages(messages, config, now) {
|
|
|
2112
2094
|
}
|
|
2113
2095
|
//#endregion
|
|
2114
2096
|
//#region src/extensions/context-management.ts
|
|
2115
|
-
const log$
|
|
2097
|
+
const log$9 = logger.child({ module: "context-management-extension" });
|
|
2116
2098
|
function isAnthropicMessagesPayload(payload) {
|
|
2117
2099
|
if (typeof payload !== "object" || payload === null) return false;
|
|
2118
2100
|
const candidate = payload;
|
|
@@ -2177,13 +2159,13 @@ function createContextManagementExtension() {
|
|
|
2177
2159
|
setContextManagementFlagOverride(getPolledFlag("contextManagement"));
|
|
2178
2160
|
onFlagChange("contextManagement", (enabled) => {
|
|
2179
2161
|
setContextManagementFlagOverride(enabled);
|
|
2180
|
-
log$
|
|
2162
|
+
log$9.info({
|
|
2181
2163
|
event: "context_management_flag_update",
|
|
2182
2164
|
enabled
|
|
2183
2165
|
}, "context-management flag updated from platform");
|
|
2184
2166
|
});
|
|
2185
2167
|
startFeatureFlagPoller();
|
|
2186
|
-
log$
|
|
2168
|
+
log$9.info({
|
|
2187
2169
|
event: "context_management_registered",
|
|
2188
2170
|
enabled: initial.enabled,
|
|
2189
2171
|
flagSource: hasFlagSource(),
|
|
@@ -2195,13 +2177,13 @@ function createContextManagementExtension() {
|
|
|
2195
2177
|
const { messages } = event;
|
|
2196
2178
|
try {
|
|
2197
2179
|
const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
|
|
2198
|
-
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$
|
|
2180
|
+
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$9.info({
|
|
2199
2181
|
event: "context_management_applied",
|
|
2200
2182
|
...result.stats
|
|
2201
2183
|
}, "trimmed/cleared tool output before LLM call");
|
|
2202
2184
|
return { messages: result.messages };
|
|
2203
2185
|
} catch (err) {
|
|
2204
|
-
log$
|
|
2186
|
+
log$9.error({
|
|
2205
2187
|
err,
|
|
2206
2188
|
event: "context_management_transform_failed"
|
|
2207
2189
|
}, "context transform failed; passing messages through unchanged");
|
|
@@ -2213,7 +2195,7 @@ function createContextManagementExtension() {
|
|
|
2213
2195
|
}
|
|
2214
2196
|
//#endregion
|
|
2215
2197
|
//#region src/extensions/current-time.ts
|
|
2216
|
-
const log$
|
|
2198
|
+
const log$8 = logger.child({ module: "current-time-extension" });
|
|
2217
2199
|
const PI_DATE_LINE = /^Current date:.*$/m;
|
|
2218
2200
|
function formatCurrentTimeLine(now) {
|
|
2219
2201
|
return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
|
|
@@ -2226,7 +2208,7 @@ const currentTimeExtension = (pi) => {
|
|
|
2226
2208
|
const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
|
|
2227
2209
|
const base = event.systemPrompt;
|
|
2228
2210
|
if (PI_DATE_LINE.test(base)) {
|
|
2229
|
-
log$
|
|
2211
|
+
log$8.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
|
|
2230
2212
|
return { systemPrompt: base.replace(PI_DATE_LINE, line) };
|
|
2231
2213
|
}
|
|
2232
2214
|
return { systemPrompt: `${base}\n${line}` };
|
|
@@ -2463,7 +2445,7 @@ function renderIndex(entries) {
|
|
|
2463
2445
|
}
|
|
2464
2446
|
//#endregion
|
|
2465
2447
|
//#region src/extensions/memory.ts
|
|
2466
|
-
const log$
|
|
2448
|
+
const log$7 = logger.child({ module: "memory-extension" });
|
|
2467
2449
|
/**
|
|
2468
2450
|
* The standing instructions for the memory system. Always injected (even with
|
|
2469
2451
|
* an empty `.memory/`) so the agent knows it can persist notes. `users/` is
|
|
@@ -2497,7 +2479,7 @@ const memoryExtension = (pi) => {
|
|
|
2497
2479
|
index
|
|
2498
2480
|
});
|
|
2499
2481
|
} catch (err) {
|
|
2500
|
-
log$
|
|
2482
|
+
log$7.warn({
|
|
2501
2483
|
err,
|
|
2502
2484
|
event: "memory_index_failed"
|
|
2503
2485
|
}, "memory index build failed; injecting instructions only");
|
|
@@ -2511,7 +2493,7 @@ const memoryExtension = (pi) => {
|
|
|
2511
2493
|
};
|
|
2512
2494
|
//#endregion
|
|
2513
2495
|
//#region src/extensions/platform-memory.ts
|
|
2514
|
-
const log$
|
|
2496
|
+
const log$6 = logger.child({ module: "platform-memory-extension" });
|
|
2515
2497
|
/**
|
|
2516
2498
|
* Resolve the human on this turn via the API, keyed by the message id.
|
|
2517
2499
|
* `/sandbox/channel-context` only returns a sender for a platform-known
|
|
@@ -2524,13 +2506,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
|
|
|
2524
2506
|
async function resolveTurnUser(messageId) {
|
|
2525
2507
|
const client = sandboxClient();
|
|
2526
2508
|
if (!client) {
|
|
2527
|
-
log$
|
|
2509
|
+
log$6.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
|
|
2528
2510
|
return null;
|
|
2529
2511
|
}
|
|
2530
2512
|
try {
|
|
2531
2513
|
const res = await client["channel-context"].$get({ query: { messageId } });
|
|
2532
2514
|
if (!res.ok) {
|
|
2533
|
-
log$
|
|
2515
|
+
log$6.warn({
|
|
2534
2516
|
event: "resolve_turn_user_failed",
|
|
2535
2517
|
status: res.status
|
|
2536
2518
|
}, "channel-context returned non-ok; withholding user memory");
|
|
@@ -2543,7 +2525,7 @@ async function resolveTurnUser(messageId) {
|
|
|
2543
2525
|
displayName: sender.displayName
|
|
2544
2526
|
};
|
|
2545
2527
|
} catch (err) {
|
|
2546
|
-
log$
|
|
2528
|
+
log$6.warn({
|
|
2547
2529
|
err,
|
|
2548
2530
|
event: "resolve_turn_user_failed"
|
|
2549
2531
|
}, "failed to resolve current user; withholding user memory");
|
|
@@ -2590,7 +2572,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2590
2572
|
user
|
|
2591
2573
|
});
|
|
2592
2574
|
} catch (err) {
|
|
2593
|
-
log$
|
|
2575
|
+
log$6.warn({
|
|
2594
2576
|
err,
|
|
2595
2577
|
event: "user_memory_index_failed"
|
|
2596
2578
|
}, "user memory index build failed; skipping injection");
|
|
@@ -2605,7 +2587,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2605
2587
|
}
|
|
2606
2588
|
//#endregion
|
|
2607
2589
|
//#region src/extensions/self-trace.ts
|
|
2608
|
-
const log$
|
|
2590
|
+
const log$5 = logger.child({ module: "self-trace-extension" });
|
|
2609
2591
|
/**
|
|
2610
2592
|
* Reports the agent's own execution as OpenTelemetry spans:
|
|
2611
2593
|
* agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
|
|
@@ -2630,7 +2612,7 @@ const selfTraceExtension = (pi) => {
|
|
|
2630
2612
|
sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
|
|
2631
2613
|
sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
|
|
2632
2614
|
const sc = sessionSpan.spanContext();
|
|
2633
|
-
log$
|
|
2615
|
+
log$5.info({
|
|
2634
2616
|
event: "self_trace_session_start",
|
|
2635
2617
|
trace_id: sc.traceId,
|
|
2636
2618
|
span_id: sc.spanId,
|
|
@@ -2742,13 +2724,13 @@ const selfTraceExtension = (pi) => {
|
|
|
2742
2724
|
* Lives in the harness package — soul.md is content from the agent's
|
|
2743
2725
|
* own git repo, not from the platform — so its handling stays here.
|
|
2744
2726
|
*/
|
|
2745
|
-
const log$
|
|
2727
|
+
const log$4 = logger.child({ module: "soul-extension" });
|
|
2746
2728
|
async function readSoul(cwd) {
|
|
2747
2729
|
try {
|
|
2748
2730
|
return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
|
|
2749
2731
|
} catch (err) {
|
|
2750
2732
|
if (err?.code === "ENOENT") return null;
|
|
2751
|
-
log$
|
|
2733
|
+
log$4.warn({
|
|
2752
2734
|
err,
|
|
2753
2735
|
event: "soul_read_failed"
|
|
2754
2736
|
}, "soul.md read failed");
|
|
@@ -2762,7 +2744,7 @@ function soulSection(cwd, soul) {
|
|
|
2762
2744
|
|
|
2763
2745
|
**\`soul.md\` is where behavior lives.** Any standing instruction about how you should act — a rule a user wants you to follow going forward, a tone or format preference, a workflow convention, a "from now on, always/never …" — belongs here, not in \`.memory/\`. Memory records *what happened* (facts, events, findings); soul defines *how you behave*. When a user gives you a durable behavioral rule, write it to \`soul.md\`. If you find behavioral rules that ended up in \`.memory/\`, treat that as misfiled and move them here.
|
|
2764
2746
|
|
|
2765
|
-
Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch.
|
|
2747
|
+
Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect, a secret or auth credential you wire in — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Do this the moment you gain the capability, and for a credential that means the moment it verifies with a real call, not after a human points out that you forgot. Connecting a capability is itself a durable change worth recording, not merely a step toward the task in front of you. Edit \`soul.md\` (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
|
|
2766
2748
|
|
|
2767
2749
|
${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
|
|
2768
2750
|
}
|
|
@@ -2780,8 +2762,7 @@ const soulExtension = (pi) => {
|
|
|
2780
2762
|
};
|
|
2781
2763
|
//#endregion
|
|
2782
2764
|
//#region src/extensions/subagent/index.ts
|
|
2783
|
-
const log$
|
|
2784
|
-
const COLD_START_FLAG_WAIT_MS = 750;
|
|
2765
|
+
const log$3 = logger.child({ module: "subagent-ext" });
|
|
2785
2766
|
const MAX_TASKS = 8;
|
|
2786
2767
|
const TaskItem = Type.Object({
|
|
2787
2768
|
task: Type.String({ description: "The task to delegate to a subagent run." }),
|
|
@@ -2790,8 +2771,14 @@ const TaskItem = Type.Object({
|
|
|
2790
2771
|
maxLength: 120
|
|
2791
2772
|
}),
|
|
2792
2773
|
persona: Type.Optional(Type.String({ description: "Optional extra system prompt / role for this task, applied ON TOP of the child run's own default persona (your full identity and soul are still there underneath). Omit to run with just your default persona." })),
|
|
2793
|
-
model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on
|
|
2774
|
+
model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on. PREFER A LOWER-COST, FASTER MODEL when the task is well-scoped and does not need your full reasoning depth — most delegated subtasks (searching, summarizing, mechanical edits, gathering or reformatting data, running a check) run just as well on a lighter model and cost far less. Reserve a top-tier model for subtasks that genuinely need deep reasoning or careful judgment. Must be a real catalogued model id. Omit to inherit your own model. If you are locked to a Google-compliant model, only compliant models are accepted." })),
|
|
2775
|
+
timeoutMinutes: Type.Optional(Type.Integer({
|
|
2776
|
+
description: "Optional wall-clock timeout for this subagent, in minutes. If the run is still going after this long it is ended and you are rewoken with a timeout result, so a hung subagent can never strand you. Omit for the default (30 minutes). Raise it for genuinely long work (a big migration, a large audit); lower it for a quick lookup. Range 1-360.",
|
|
2777
|
+
minimum: 1,
|
|
2778
|
+
maximum: 360
|
|
2779
|
+
}))
|
|
2794
2780
|
});
|
|
2781
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
|
|
2795
2782
|
const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
|
|
2796
2783
|
description: "One or more tasks to delegate. Each spawns an isolated subagent run linked to this conversation; they run in parallel and each rewakes you with its result when it finishes.",
|
|
2797
2784
|
minItems: 1,
|
|
@@ -2806,7 +2793,8 @@ function buildTool(messageId) {
|
|
|
2806
2793
|
"Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
|
|
2807
2794
|
"Use it to parallelize independent work, to keep a large or noisy subtask out of your own context, or to run a task under a specialized persona.",
|
|
2808
2795
|
"Fire-and-forget: this returns immediately after queueing. It does NOT wait for results. Each subagent runs on its own and, when it finishes, sends you its result on this thread — so queue the work, then keep going or end your turn. To chain, re-delegate after a result lands.",
|
|
2809
|
-
"Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task; omit it to
|
|
2796
|
+
"Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task — prefer a lower-cost, faster model for well-scoped subtasks that don't need deep reasoning, and reserve a top-tier model for the ones that do; omit it to inherit your own model.",
|
|
2797
|
+
"Peering: each queued task comes back with its own conversation id. A subagent is a real linked conversation, so to see what one is doing RIGHT NOW while it runs — its reasoning, the tools it has called and their results, its progress — read that conversation with `platform conversations show <conversationId>` (you are already authorized; it is your own delegated run). Check in that way instead of waiting blind for the final result. The read reflects the child's persisted state, which lags a few seconds behind live (tool results land as they complete; in-progress reasoning can be up to ~5s stale), so peek between checkpoints rather than polling in a tight loop."
|
|
2810
2798
|
].join(" "),
|
|
2811
2799
|
promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
|
|
2812
2800
|
parameters: SubagentParams,
|
|
@@ -2824,28 +2812,39 @@ function buildTool(messageId) {
|
|
|
2824
2812
|
task: t.task,
|
|
2825
2813
|
title: t.title ?? null,
|
|
2826
2814
|
persona: t.persona ?? null,
|
|
2827
|
-
model: t.model ?? null
|
|
2815
|
+
model: t.model ?? null,
|
|
2816
|
+
timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
|
|
2828
2817
|
}));
|
|
2829
2818
|
try {
|
|
2830
|
-
const
|
|
2819
|
+
const spawned = await postSubagentSpawn({
|
|
2831
2820
|
messageId,
|
|
2832
2821
|
tasks: spawnTasks
|
|
2833
2822
|
});
|
|
2834
|
-
|
|
2823
|
+
const { taskIds } = spawned;
|
|
2824
|
+
log$3.info({
|
|
2835
2825
|
event: "subagent_spawned",
|
|
2836
2826
|
count: taskIds.length
|
|
2837
2827
|
}, "subagent tasks queued");
|
|
2838
|
-
const
|
|
2828
|
+
const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
|
|
2829
|
+
const lines = taskIds.map((id, i) => {
|
|
2830
|
+
const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
|
|
2831
|
+
const conv = convByTask.get(id);
|
|
2832
|
+
return `- ${id}: ${label}${conv ? ` — peek: conversations show ${conv}` : ""}`;
|
|
2833
|
+
}).join("\n");
|
|
2834
|
+
const peerHint = spawned.tasks.length ? "\nTo see what a subagent is doing while it runs, read its conversation with `platform conversations show <conversationId>` (shown per task above)." : "";
|
|
2839
2835
|
return {
|
|
2840
2836
|
content: [{
|
|
2841
2837
|
type: "text",
|
|
2842
|
-
text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}`
|
|
2838
|
+
text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}${peerHint}`
|
|
2843
2839
|
}],
|
|
2844
|
-
details: {
|
|
2840
|
+
details: {
|
|
2841
|
+
taskIds,
|
|
2842
|
+
tasks: spawned.tasks
|
|
2843
|
+
}
|
|
2845
2844
|
};
|
|
2846
2845
|
} catch (err) {
|
|
2847
2846
|
const message = err instanceof Error ? err.message : String(err);
|
|
2848
|
-
log$
|
|
2847
|
+
log$3.warn({
|
|
2849
2848
|
err,
|
|
2850
2849
|
event: "subagent_spawn_failed"
|
|
2851
2850
|
}, "subagent spawn failed");
|
|
@@ -2862,39 +2861,249 @@ function buildTool(messageId) {
|
|
|
2862
2861
|
};
|
|
2863
2862
|
}
|
|
2864
2863
|
/**
|
|
2865
|
-
* Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
|
|
2866
2864
|
* The factory takes the session's channel context to resolve the originating
|
|
2867
2865
|
* messageId — the api links each spawned run to the conversation that message
|
|
2868
2866
|
* belongs to and rewakes it on completion (nothing about the parent is piped
|
|
2869
|
-
* from the sandbox beyond that id).
|
|
2867
|
+
* from the sandbox beyond that id). The tool is registered unconditionally at
|
|
2868
|
+
* session_start.
|
|
2870
2869
|
*/
|
|
2871
2870
|
function createSubagentExtension({ channelContext }) {
|
|
2872
2871
|
return (pi) => {
|
|
2873
2872
|
const messageId = extractMessageId(channelContext);
|
|
2874
|
-
startFeatureFlagPoller();
|
|
2875
2873
|
let registered = false;
|
|
2876
2874
|
const registerOnce = () => {
|
|
2877
2875
|
if (registered) return;
|
|
2878
2876
|
registered = true;
|
|
2879
2877
|
pi.registerTool(buildTool(messageId));
|
|
2880
|
-
log$
|
|
2878
|
+
log$3.info({ event: "subagent_enabled" }, "subagent tool registered");
|
|
2881
2879
|
};
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
});
|
|
2885
|
-
pi.on("session_start", async () => {
|
|
2886
|
-
if (getPolledFlag("subagent") === null) await Promise.race([awaitFirstFlagPoll(), new Promise((resolve) => setTimeout(resolve, COLD_START_FLAG_WAIT_MS).unref?.())]);
|
|
2887
|
-
if (getPolledFlag("subagent") === true) registerOnce();
|
|
2880
|
+
pi.on("session_start", () => {
|
|
2881
|
+
registerOnce();
|
|
2888
2882
|
});
|
|
2889
2883
|
};
|
|
2890
2884
|
}
|
|
2891
2885
|
//#endregion
|
|
2886
|
+
//#region src/extensions/resource-pressure-warning.ts
|
|
2887
|
+
/**
|
|
2888
|
+
* Mid-run resource-pressure warning to the agent.
|
|
2889
|
+
*
|
|
2890
|
+
* The sandbox already detects pressure — the boot scripts cap the
|
|
2891
|
+
* user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
|
|
2892
|
+
* edges for memory and disk — but nothing told the *agent*, so a turn burned
|
|
2893
|
+
* straight to the OOM kill (or a full disk) and only learned about it from
|
|
2894
|
+
* the post-mortem notice. This extension closes that gap in-process: while a
|
|
2895
|
+
* turn is active it polls the agent cgroup and the root filesystem and, the
|
|
2896
|
+
* first time usage crosses a warn threshold, folds a system notification into
|
|
2897
|
+
* the open turn so the agent can checkpoint, shed work (constrain
|
|
2898
|
+
* parallelism, kill a background hog, clean scratch space), or request a
|
|
2899
|
+
* bigger tier BEFORE the kill.
|
|
2900
|
+
*
|
|
2901
|
+
* The notification is triggered by the two conditions that actually kill
|
|
2902
|
+
* work — memory near the cgroup hard cap, disk near full — and reports a
|
|
2903
|
+
* snapshot of all the relevant stats (memory, CPU utilization, disk) so the
|
|
2904
|
+
* agent can tell which resource is the problem and how much headroom the
|
|
2905
|
+
* others have.
|
|
2906
|
+
*
|
|
2907
|
+
* Edge-triggered, once per trigger per turn: the fired flags reset on
|
|
2908
|
+
* agent_start, so a turn that rides a threshold gets one warning per
|
|
2909
|
+
* resource, not a stream. Polling only runs while the agent is active — an
|
|
2910
|
+
* idle sandbox's resource usage is not the agent's problem and there is no
|
|
2911
|
+
* open turn to deliver into anyway.
|
|
2912
|
+
*
|
|
2913
|
+
* Best-effort throughout: any read failure (cgroup absent, controller not
|
|
2914
|
+
* delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
|
|
2915
|
+
* stat and the extension warns on what it can see — it must never break a
|
|
2916
|
+
* turn over an observability feature.
|
|
2917
|
+
*/
|
|
2918
|
+
const execFileAsync = promisify(execFile);
|
|
2919
|
+
const log$2 = logger.child({ module: "resource-pressure-warning" });
|
|
2920
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
2921
|
+
function envOverride(name) {
|
|
2922
|
+
for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
|
|
2923
|
+
const value = process.env[`${prefix}${name}`];
|
|
2924
|
+
if (value != null && value !== "") return value;
|
|
2925
|
+
}
|
|
2926
|
+
return null;
|
|
2927
|
+
}
|
|
2928
|
+
function cgroupDir() {
|
|
2929
|
+
return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
|
|
2930
|
+
}
|
|
2931
|
+
function diskRoot() {
|
|
2932
|
+
return envOverride("DISK_ROOT") ?? "/";
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Read a cgroup v2 scalar file. Returns a number, or null for "max"
|
|
2936
|
+
* (uncapped), an empty/absent file, or any read/parse error — an uncapped or
|
|
2937
|
+
* unreadable limit means there is nothing meaningful to warn against.
|
|
2938
|
+
*/
|
|
2939
|
+
async function readScalar(file) {
|
|
2940
|
+
try {
|
|
2941
|
+
const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
|
|
2942
|
+
if (raw === "" || raw === "max") return null;
|
|
2943
|
+
const n = Number(raw);
|
|
2944
|
+
return Number.isFinite(n) ? n : null;
|
|
2945
|
+
} catch (_error) {
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
|
|
2951
|
+
* cpu.stat) and return the counter for `key`, or null when absent.
|
|
2952
|
+
*/
|
|
2953
|
+
async function readKeyedCounter(file, key) {
|
|
2954
|
+
try {
|
|
2955
|
+
const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
|
|
2956
|
+
for (const line of raw.split("\n")) {
|
|
2957
|
+
const [k, v] = line.trim().split(/\s+/);
|
|
2958
|
+
if (k === key) {
|
|
2959
|
+
const n = Number(v);
|
|
2960
|
+
return Number.isFinite(n) ? n : null;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
return null;
|
|
2964
|
+
} catch (_error) {
|
|
2965
|
+
return null;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Live memory usage as an integer percent of the hard cap, or null when
|
|
2970
|
+
* either side is unreadable/uncapped. Exported for tests.
|
|
2971
|
+
*/
|
|
2972
|
+
async function readMemUsePct() {
|
|
2973
|
+
const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
|
|
2974
|
+
if (current === null || max === null || max <= 0) return null;
|
|
2975
|
+
return {
|
|
2976
|
+
pct: Math.floor(current / max * 100),
|
|
2977
|
+
currentBytes: current,
|
|
2978
|
+
maxBytes: max
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* Root filesystem used% (df -P Capacity column), or null on any failure.
|
|
2983
|
+
* Exported for tests.
|
|
2984
|
+
*/
|
|
2985
|
+
async function readDiskUsePct() {
|
|
2986
|
+
try {
|
|
2987
|
+
const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
|
|
2988
|
+
const dataRow = stdout.trim().split("\n")[1];
|
|
2989
|
+
if (dataRow == null) return null;
|
|
2990
|
+
const capacity = dataRow.trim().split(/\s+/)[4];
|
|
2991
|
+
if (capacity == null) return null;
|
|
2992
|
+
const pct = Number(capacity.replace("%", ""));
|
|
2993
|
+
return Number.isFinite(pct) ? pct : null;
|
|
2994
|
+
} catch (_error) {
|
|
2995
|
+
return null;
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* CPU utilization sampler. cgroup v2 exposes cumulative CPU time
|
|
3000
|
+
* (cpu.stat usage_usec); utilization is the delta between two samples over
|
|
3001
|
+
* the wall time between them, normalized by core count. The first call after
|
|
3002
|
+
* construction has no previous sample and returns null.
|
|
3003
|
+
*/
|
|
3004
|
+
function createCpuSampler() {
|
|
3005
|
+
let prevUsageUsec = null;
|
|
3006
|
+
let prevAtMs = null;
|
|
3007
|
+
return async () => {
|
|
3008
|
+
const usage = await readKeyedCounter("cpu.stat", "usage_usec");
|
|
3009
|
+
const now = Date.now();
|
|
3010
|
+
const prev = prevUsageUsec;
|
|
3011
|
+
const prevAt = prevAtMs;
|
|
3012
|
+
prevUsageUsec = usage;
|
|
3013
|
+
prevAtMs = now;
|
|
3014
|
+
if (usage === null || prev === null || prevAt === null) return null;
|
|
3015
|
+
const wallUsec = (now - prevAt) * 1e3;
|
|
3016
|
+
if (wallUsec <= 0) return null;
|
|
3017
|
+
const cores = availableParallelism();
|
|
3018
|
+
const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
|
|
3019
|
+
return Math.max(0, Math.min(100, pct));
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
function fmtMb(bytes) {
|
|
3023
|
+
return Math.round(bytes / 1024 / 1024);
|
|
3024
|
+
}
|
|
3025
|
+
/** The model-facing warning text. Exported for tests. */
|
|
3026
|
+
function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
|
|
3027
|
+
const stats = [];
|
|
3028
|
+
if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
|
|
3029
|
+
if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
|
|
3030
|
+
if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
|
|
3031
|
+
const lead = trigger === "memory" ? `Your sandbox is at ${mem?.pct}% of its memory cap. If usage keeps climbing, the kernel will kill the offending process and this turn may die with it.` : `Your sandbox's disk is ${diskPct}% full. If it fills completely, writes will start failing and this turn may die with them.`;
|
|
3032
|
+
const remedy = trigger === "memory" ? "checkpoint in-flight work (commit and push), then reduce the footprint — constrain parallelism, run heavy steps sequentially, or kill background processes you no longer need." : "checkpoint in-flight work (commit and push), then free space — clean build artifacts, caches, and scratch files you no longer need.";
|
|
3033
|
+
return `<system_notification>${lead} Current usage: ${stats.join(", ")}. Act now: ${remedy} If the workload genuinely needs more resources, request a bigger sandbox with \`platform compute request\`. This is an automated resource warning, not a message from the user; continue the task, adjusted.</system_notification>`;
|
|
3034
|
+
}
|
|
3035
|
+
const resourcePressureWarningExtension = (pi) => {
|
|
3036
|
+
let agentActive = false;
|
|
3037
|
+
let warnedMemThisTurn = false;
|
|
3038
|
+
let warnedDiskThisTurn = false;
|
|
3039
|
+
let timer = null;
|
|
3040
|
+
const sampleCpu = createCpuSampler();
|
|
3041
|
+
async function checkOnce() {
|
|
3042
|
+
if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
|
|
3043
|
+
const [mem, cpuPct, diskPct] = await Promise.all([
|
|
3044
|
+
readMemUsePct(),
|
|
3045
|
+
sampleCpu(),
|
|
3046
|
+
readDiskUsePct()
|
|
3047
|
+
]);
|
|
3048
|
+
let trigger = null;
|
|
3049
|
+
if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
|
|
3050
|
+
trigger = "memory";
|
|
3051
|
+
warnedMemThisTurn = true;
|
|
3052
|
+
} else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
|
|
3053
|
+
trigger = "disk";
|
|
3054
|
+
warnedDiskThisTurn = true;
|
|
3055
|
+
}
|
|
3056
|
+
if (trigger === null) return;
|
|
3057
|
+
log$2.warn({
|
|
3058
|
+
trigger,
|
|
3059
|
+
mem,
|
|
3060
|
+
cpuPct,
|
|
3061
|
+
diskPct
|
|
3062
|
+
}, "resource pressure warning delivered to agent");
|
|
3063
|
+
await pi.sendMessage({
|
|
3064
|
+
customType: "anyone-resource-pressure-warning",
|
|
3065
|
+
content: resourcePressureWarningText(trigger, {
|
|
3066
|
+
mem,
|
|
3067
|
+
cpuPct,
|
|
3068
|
+
diskPct
|
|
3069
|
+
}),
|
|
3070
|
+
display: false
|
|
3071
|
+
}, {
|
|
3072
|
+
triggerTurn: true,
|
|
3073
|
+
deliverAs: "followUp"
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
pi.on("agent_start", async () => {
|
|
3077
|
+
agentActive = true;
|
|
3078
|
+
warnedMemThisTurn = false;
|
|
3079
|
+
warnedDiskThisTurn = false;
|
|
3080
|
+
if (!timer) {
|
|
3081
|
+
timer = setInterval(() => {
|
|
3082
|
+
checkOnce().catch((err) => {
|
|
3083
|
+
log$2.error({ err }, "resource pressure check failed");
|
|
3084
|
+
});
|
|
3085
|
+
}, POLL_INTERVAL_MS);
|
|
3086
|
+
timer.unref?.();
|
|
3087
|
+
}
|
|
3088
|
+
});
|
|
3089
|
+
pi.on("agent_end", async () => {
|
|
3090
|
+
agentActive = false;
|
|
3091
|
+
if (timer) {
|
|
3092
|
+
clearInterval(timer);
|
|
3093
|
+
timer = null;
|
|
3094
|
+
}
|
|
3095
|
+
});
|
|
3096
|
+
};
|
|
3097
|
+
//#endregion
|
|
2892
3098
|
//#region src/extensions/tool-call-env.ts
|
|
2893
3099
|
const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
|
|
3100
|
+
function shellQuoteValue(value) {
|
|
3101
|
+
return quote([value]);
|
|
3102
|
+
}
|
|
2894
3103
|
function withToolCallId({ command, toolCallId }) {
|
|
2895
|
-
return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
|
|
3104
|
+
return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
|
|
2896
3105
|
}
|
|
2897
|
-
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
|
|
3106
|
+
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
|
|
2898
3107
|
function stripPlatformExportsForDisplay(command) {
|
|
2899
3108
|
let c = command;
|
|
2900
3109
|
let m;
|
|
@@ -3659,7 +3868,8 @@ function platformExtensions({ sessionId, channelContext }) {
|
|
|
3659
3868
|
selfTraceExtension,
|
|
3660
3869
|
createBackgroundTasksExtension({ channelContext }),
|
|
3661
3870
|
createSubagentExtension({ channelContext }),
|
|
3662
|
-
createContextManagementExtension()
|
|
3871
|
+
createContextManagementExtension(),
|
|
3872
|
+
resourcePressureWarningExtension
|
|
3663
3873
|
];
|
|
3664
3874
|
}
|
|
3665
3875
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skydiveai/pi-extensions",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.763",
|
|
4
4
|
"homepage": "https://skydive.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Create, Inc.",
|
|
@@ -17,12 +17,6 @@
|
|
|
17
17
|
},
|
|
18
18
|
"publishConfig": {
|
|
19
19
|
"access": "public",
|
|
20
|
-
"exports": {
|
|
21
|
-
".": {
|
|
22
|
-
"types": "./dist/index.d.mts",
|
|
23
|
-
"default": "./dist/index.mjs"
|
|
24
|
-
}
|
|
25
|
-
},
|
|
26
20
|
"registry": "https://registry.npmjs.org"
|
|
27
21
|
},
|
|
28
22
|
"scripts": {
|
|
@@ -45,6 +39,7 @@
|
|
|
45
39
|
"@skydiveai/pi-server": "^0.1.0",
|
|
46
40
|
"hono": "^4.6.14",
|
|
47
41
|
"pino": "^9.6.0",
|
|
42
|
+
"shell-quote": "^1.8.4",
|
|
48
43
|
"typebox": "^1.1.34",
|
|
49
44
|
"yaml": "^2.8.3",
|
|
50
45
|
"zod": "^3.25.0"
|