@skydiveai/pi-extensions 0.1.0-beta.9 → 0.1.0-beta.900
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 +406 -97
- 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");
|
|
@@ -1679,20 +1683,77 @@ function apiBaseUrl() {
|
|
|
1679
1683
|
}
|
|
1680
1684
|
//#endregion
|
|
1681
1685
|
//#region src/extensions/platform.ts
|
|
1686
|
+
/**
|
|
1687
|
+
* Platform extension — bridges the agent harness to the Skydive platform daemon.
|
|
1688
|
+
*
|
|
1689
|
+
* Responsibilities:
|
|
1690
|
+
* - Heartbeat: periodic POST to the API so the sandbox manager knows the
|
|
1691
|
+
* agent is alive. Throttled to once per minute, triggered by tool events.
|
|
1692
|
+
* - Session tracking: registers the session with the daemon on start,
|
|
1693
|
+
* streams tool_call / tool_result events so the daemon can track which
|
|
1694
|
+
* session is actively executing, and signals session end on agent_end.
|
|
1695
|
+
* - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
|
|
1696
|
+
* messageId) to the daemon so file writes can be attributed to the
|
|
1697
|
+
* correct conversation.
|
|
1698
|
+
*
|
|
1699
|
+
* All daemon POSTs are fire-and-forget — failures are logged but never
|
|
1700
|
+
* block the agent. The daemon may not be running (e.g. local dev without
|
|
1701
|
+
* a sandbox), and that's fine.
|
|
1702
|
+
*/
|
|
1682
1703
|
const HEARTBEAT_THROTTLE_MS = 6e4;
|
|
1683
1704
|
const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1684
1705
|
const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
|
|
1685
1706
|
const DAEMON_URL = "http://localhost:38994";
|
|
1686
|
-
const log$
|
|
1707
|
+
const log$11 = logger.child({ module: "platform-ext" });
|
|
1687
1708
|
function sandboxClient() {
|
|
1688
1709
|
const apiUrl = apiBaseUrl();
|
|
1689
1710
|
if (!apiUrl) return null;
|
|
1690
1711
|
return hc(`${apiUrl}/api/v1/sandbox`);
|
|
1691
1712
|
}
|
|
1692
1713
|
/**
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1714
|
+
* Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
|
|
1715
|
+
* feature-flags half of the ANY-5184 pool 403 wave.)
|
|
1716
|
+
*
|
|
1717
|
+
* `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
|
|
1718
|
+
* from a pool box can only 403 — a guaranteed-failing GET every 60s for the
|
|
1719
|
+
* life of the pool phase. The discriminator is the sandbox token's `type`
|
|
1720
|
+
* claim, read UNVERIFIED (this box never holds the signing secret): not an
|
|
1721
|
+
* authorization decision, only "should I bother calling?", and the api still
|
|
1722
|
+
* authorizes every request.
|
|
1723
|
+
*
|
|
1724
|
+
* Read per call from the daemon's persisted env file, NOT process.env:
|
|
1725
|
+
* claiming a pool box rebinds the token in place (the daemon rewrites this
|
|
1726
|
+
* file) while the harness's process.env keeps the boot snapshot, so a
|
|
1727
|
+
* process-env gate would leave a claimed box permanently skipping — trading a
|
|
1728
|
+
* wasted request for silently frozen flags, which is strictly worse. "Cannot
|
|
1729
|
+
* tell" (no file, no token, unparseable payload) reports false so the poll
|
|
1730
|
+
* proceeds.
|
|
1731
|
+
*/
|
|
1732
|
+
const daemonEnvIdentitySchema = z.object({
|
|
1733
|
+
ANYONE_SANDBOX_TOKEN: z.string().optional(),
|
|
1734
|
+
SKYDIVE_SANDBOX_TOKEN: z.string().optional()
|
|
1735
|
+
}).passthrough();
|
|
1736
|
+
const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
|
|
1737
|
+
async function isPoolIdentity() {
|
|
1738
|
+
try {
|
|
1739
|
+
const envFile = process.env.ANYONE_DAEMON_ENV_CACHE ?? "/tmp/.anyone/daemon-env.json";
|
|
1740
|
+
const env = daemonEnvIdentitySchema.safeParse(JSON.parse(await readFile(envFile, "utf8")));
|
|
1741
|
+
if (!env.success) return false;
|
|
1742
|
+
const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
|
|
1743
|
+
if (typeof token !== "string" || token === "") return false;
|
|
1744
|
+
const payload = token.split(".")[1];
|
|
1745
|
+
if (!payload) return false;
|
|
1746
|
+
const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
1747
|
+
return claims.success && claims.data.type === "onboarding-pool";
|
|
1748
|
+
} catch (_err) {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
|
|
1754
|
+
* — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
|
|
1755
|
+
* null when indeterminate (no api url, the request failed, or the box is an
|
|
1756
|
+
* unclaimed pool sandbox whose token the route would 403) so the shared
|
|
1696
1757
|
* poller keeps the last-known values rather than flipping on a transient error.
|
|
1697
1758
|
* This is the single fetch behind `feature-flags-poll.ts`; extensions read the
|
|
1698
1759
|
* polled values there instead of issuing their own GET.
|
|
@@ -1700,22 +1761,19 @@ function sandboxClient() {
|
|
|
1700
1761
|
async function fetchHarnessFlags() {
|
|
1701
1762
|
const client = sandboxClient();
|
|
1702
1763
|
if (!client) return null;
|
|
1764
|
+
if (await isPoolIdentity()) return null;
|
|
1703
1765
|
try {
|
|
1704
1766
|
const res = await client["feature-flags"].$get();
|
|
1705
1767
|
if (!res.ok) {
|
|
1706
|
-
log$
|
|
1768
|
+
log$11.debug({
|
|
1707
1769
|
status: res.status,
|
|
1708
1770
|
event: "feature_flags_fetch_failed"
|
|
1709
1771
|
}, "feature-flags fetch failed");
|
|
1710
1772
|
return null;
|
|
1711
1773
|
}
|
|
1712
|
-
|
|
1713
|
-
return {
|
|
1714
|
-
contextManagement: body.contextManagement ?? null,
|
|
1715
|
-
subagent: body.subagent ?? null
|
|
1716
|
-
};
|
|
1774
|
+
return { contextManagement: (await res.json()).contextManagement ?? null };
|
|
1717
1775
|
} catch (err) {
|
|
1718
|
-
log$
|
|
1776
|
+
log$11.debug({
|
|
1719
1777
|
err,
|
|
1720
1778
|
event: "feature_flags_fetch_error"
|
|
1721
1779
|
}, "feature-flags request errored");
|
|
@@ -1726,7 +1784,7 @@ function postHeartbeat({ messageId }) {
|
|
|
1726
1784
|
const client = sandboxClient();
|
|
1727
1785
|
if (!client) return;
|
|
1728
1786
|
client.heartbeat.$post({ json: { messageId } }).catch((err) => {
|
|
1729
|
-
log$
|
|
1787
|
+
log$11.debug({
|
|
1730
1788
|
err,
|
|
1731
1789
|
event: "heartbeat_failed"
|
|
1732
1790
|
}, "heartbeat failed");
|
|
@@ -1738,7 +1796,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1738
1796
|
try {
|
|
1739
1797
|
const res = await client["message-conversation"].$get({ query: { messageId } });
|
|
1740
1798
|
if (!res.ok) {
|
|
1741
|
-
log$
|
|
1799
|
+
log$11.warn({
|
|
1742
1800
|
status: res.status,
|
|
1743
1801
|
messageId,
|
|
1744
1802
|
event: "resolve_conversation_failed"
|
|
@@ -1747,7 +1805,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1747
1805
|
}
|
|
1748
1806
|
return (await res.json()).conversationId ?? null;
|
|
1749
1807
|
} catch (err) {
|
|
1750
|
-
log$
|
|
1808
|
+
log$11.warn({
|
|
1751
1809
|
err,
|
|
1752
1810
|
messageId,
|
|
1753
1811
|
event: "resolve_conversation_error"
|
|
@@ -1772,7 +1830,11 @@ async function postSubagentSpawn({ messageId, tasks }) {
|
|
|
1772
1830
|
tasks
|
|
1773
1831
|
} });
|
|
1774
1832
|
if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
|
|
1775
|
-
|
|
1833
|
+
const body = await res.json();
|
|
1834
|
+
return {
|
|
1835
|
+
taskIds: body.taskIds,
|
|
1836
|
+
tasks: body.tasks ?? []
|
|
1837
|
+
};
|
|
1776
1838
|
}
|
|
1777
1839
|
function createHeartbeatThrottle({ messageId }) {
|
|
1778
1840
|
let lastAt = 0;
|
|
@@ -1821,7 +1883,7 @@ function createToolHeartbeat({ messageId }) {
|
|
|
1821
1883
|
}
|
|
1822
1884
|
heartbeatCount++;
|
|
1823
1885
|
if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
|
|
1824
|
-
log$
|
|
1886
|
+
log$11.warn({
|
|
1825
1887
|
heartbeatCount,
|
|
1826
1888
|
activeToolCalls: [...activeToolCalls]
|
|
1827
1889
|
}, "tool heartbeat max reached, stopping");
|
|
@@ -1852,7 +1914,7 @@ function postToDaemon(path, body) {
|
|
|
1852
1914
|
headers: { "content-type": "application/json" },
|
|
1853
1915
|
body: JSON.stringify(body)
|
|
1854
1916
|
}).catch((err) => {
|
|
1855
|
-
log$
|
|
1917
|
+
log$11.debug({
|
|
1856
1918
|
err,
|
|
1857
1919
|
path,
|
|
1858
1920
|
event: "daemon_post_failed"
|
|
@@ -1861,7 +1923,7 @@ function postToDaemon(path, body) {
|
|
|
1861
1923
|
}
|
|
1862
1924
|
function createPlatformExtensions({ sessionId, channelContext }) {
|
|
1863
1925
|
return (pi) => {
|
|
1864
|
-
log$
|
|
1926
|
+
log$11.info({
|
|
1865
1927
|
sessionId,
|
|
1866
1928
|
hasChannelContext: Boolean(channelContext)
|
|
1867
1929
|
}, "platform extension initialized");
|
|
@@ -1910,7 +1972,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1910
1972
|
});
|
|
1911
1973
|
});
|
|
1912
1974
|
pi.on("agent_end", () => {
|
|
1913
|
-
log$
|
|
1975
|
+
log$11.info({ sessionId }, "session ending");
|
|
1914
1976
|
postToDaemon("/session/end", { sessionId });
|
|
1915
1977
|
});
|
|
1916
1978
|
};
|
|
@@ -1921,37 +1983,39 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1921
1983
|
* Shared harness feature-flag poll.
|
|
1922
1984
|
*
|
|
1923
1985
|
* The api exposes one `/feature-flags` GET that returns every harness flag in a
|
|
1924
|
-
* single response (`{ contextManagement,
|
|
1986
|
+
* single response (`{ contextManagement, commandFlags }` — see
|
|
1925
1987
|
* apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
|
|
1926
1988
|
* extension issuing its own GET — and, worse, a *blocking* GET on the
|
|
1927
1989
|
* pre-first-token `session_start` path — a single background poller fetches
|
|
1928
1990
|
* that response once per interval and fans the values out to every subscriber.
|
|
1929
1991
|
*
|
|
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.
|
|
1992
|
+
* Why one poller: context-management consumes the `contextManagement` flag
|
|
1993
|
+
* without a blocking GET on the pre-first-token `session_start` path. Reading
|
|
1994
|
+
* the last-polled value keeps the hot path allocation-only; a cold cache reads
|
|
1995
|
+
* as `null` and a newly-flipped flag takes effect on the next poll.
|
|
1938
1996
|
*
|
|
1939
1997
|
* The poll is fire-and-forget and self-unref'd — it never keeps the process
|
|
1940
1998
|
* alive and an indeterminate result (no api url / transient failure) leaves the
|
|
1941
1999
|
* last-known values untouched so a blip can't silently flip behavior.
|
|
1942
2000
|
*/
|
|
1943
|
-
const log$
|
|
2001
|
+
const log$10 = logger.child({ module: "feature-flags-poll" });
|
|
1944
2002
|
const FLAG_POLL_INTERVAL_MS = 6e4;
|
|
1945
2003
|
let contextManagement = null;
|
|
1946
|
-
|
|
1947
|
-
const subscribers = {
|
|
1948
|
-
contextManagement: /* @__PURE__ */ new Set(),
|
|
1949
|
-
subagent: /* @__PURE__ */ new Set()
|
|
1950
|
-
};
|
|
2004
|
+
const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
|
|
1951
2005
|
let pollerStarted = false;
|
|
2006
|
+
let firstPollSettled = false;
|
|
2007
|
+
let resolveFirstPoll = null;
|
|
2008
|
+
new Promise((resolve) => {
|
|
2009
|
+
resolveFirstPoll = resolve;
|
|
2010
|
+
});
|
|
2011
|
+
function markFirstPollSettled() {
|
|
2012
|
+
if (firstPollSettled) return;
|
|
2013
|
+
firstPollSettled = true;
|
|
2014
|
+
resolveFirstPoll?.();
|
|
2015
|
+
}
|
|
1952
2016
|
/** Last-polled value of a flag, or `null` if not yet resolved. */
|
|
1953
|
-
function getPolledFlag(
|
|
1954
|
-
return
|
|
2017
|
+
function getPolledFlag(_name) {
|
|
2018
|
+
return contextManagement;
|
|
1955
2019
|
}
|
|
1956
2020
|
/**
|
|
1957
2021
|
* Subscribe to changes of a flag. The callback fires only on a *transition*
|
|
@@ -1964,23 +2028,25 @@ function onFlagChange(name, cb) {
|
|
|
1964
2028
|
}
|
|
1965
2029
|
function apply(name, next) {
|
|
1966
2030
|
if (next === null) return;
|
|
1967
|
-
const prev =
|
|
1968
|
-
|
|
1969
|
-
else subagent = next;
|
|
2031
|
+
const prev = contextManagement;
|
|
2032
|
+
contextManagement = next;
|
|
1970
2033
|
if (next !== prev) for (const cb of subscribers[name]) try {
|
|
1971
2034
|
cb(next);
|
|
1972
2035
|
} catch (err) {
|
|
1973
|
-
log$
|
|
2036
|
+
log$10.warn({
|
|
1974
2037
|
err,
|
|
1975
2038
|
flag: name
|
|
1976
2039
|
}, "flag subscriber threw");
|
|
1977
2040
|
}
|
|
1978
2041
|
}
|
|
1979
2042
|
async function pollOnce() {
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2043
|
+
try {
|
|
2044
|
+
const flags = await fetchHarnessFlags();
|
|
2045
|
+
if (!flags) return;
|
|
2046
|
+
apply("contextManagement", flags.contextManagement ?? null);
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
log$10.debug({ err }, "feature-flag poll threw");
|
|
2049
|
+
}
|
|
1984
2050
|
}
|
|
1985
2051
|
/**
|
|
1986
2052
|
* Start the shared background poll (idempotent). No-op when there's no
|
|
@@ -1991,7 +2057,7 @@ async function pollOnce() {
|
|
|
1991
2057
|
function startFeatureFlagPoller() {
|
|
1992
2058
|
if (pollerStarted || !hasFlagSource()) return;
|
|
1993
2059
|
pollerStarted = true;
|
|
1994
|
-
pollOnce();
|
|
2060
|
+
pollOnce().finally(markFirstPollSettled);
|
|
1995
2061
|
setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
|
|
1996
2062
|
}
|
|
1997
2063
|
//#endregion
|
|
@@ -2086,7 +2152,7 @@ function transformContextMessages(messages, config, now) {
|
|
|
2086
2152
|
}
|
|
2087
2153
|
//#endregion
|
|
2088
2154
|
//#region src/extensions/context-management.ts
|
|
2089
|
-
const log$
|
|
2155
|
+
const log$9 = logger.child({ module: "context-management-extension" });
|
|
2090
2156
|
function isAnthropicMessagesPayload(payload) {
|
|
2091
2157
|
if (typeof payload !== "object" || payload === null) return false;
|
|
2092
2158
|
const candidate = payload;
|
|
@@ -2151,13 +2217,13 @@ function createContextManagementExtension() {
|
|
|
2151
2217
|
setContextManagementFlagOverride(getPolledFlag("contextManagement"));
|
|
2152
2218
|
onFlagChange("contextManagement", (enabled) => {
|
|
2153
2219
|
setContextManagementFlagOverride(enabled);
|
|
2154
|
-
log$
|
|
2220
|
+
log$9.info({
|
|
2155
2221
|
event: "context_management_flag_update",
|
|
2156
2222
|
enabled
|
|
2157
2223
|
}, "context-management flag updated from platform");
|
|
2158
2224
|
});
|
|
2159
2225
|
startFeatureFlagPoller();
|
|
2160
|
-
log$
|
|
2226
|
+
log$9.info({
|
|
2161
2227
|
event: "context_management_registered",
|
|
2162
2228
|
enabled: initial.enabled,
|
|
2163
2229
|
flagSource: hasFlagSource(),
|
|
@@ -2169,13 +2235,13 @@ function createContextManagementExtension() {
|
|
|
2169
2235
|
const { messages } = event;
|
|
2170
2236
|
try {
|
|
2171
2237
|
const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
|
|
2172
|
-
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$
|
|
2238
|
+
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$9.info({
|
|
2173
2239
|
event: "context_management_applied",
|
|
2174
2240
|
...result.stats
|
|
2175
2241
|
}, "trimmed/cleared tool output before LLM call");
|
|
2176
2242
|
return { messages: result.messages };
|
|
2177
2243
|
} catch (err) {
|
|
2178
|
-
log$
|
|
2244
|
+
log$9.error({
|
|
2179
2245
|
err,
|
|
2180
2246
|
event: "context_management_transform_failed"
|
|
2181
2247
|
}, "context transform failed; passing messages through unchanged");
|
|
@@ -2187,7 +2253,7 @@ function createContextManagementExtension() {
|
|
|
2187
2253
|
}
|
|
2188
2254
|
//#endregion
|
|
2189
2255
|
//#region src/extensions/current-time.ts
|
|
2190
|
-
const log$
|
|
2256
|
+
const log$8 = logger.child({ module: "current-time-extension" });
|
|
2191
2257
|
const PI_DATE_LINE = /^Current date:.*$/m;
|
|
2192
2258
|
function formatCurrentTimeLine(now) {
|
|
2193
2259
|
return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
|
|
@@ -2200,7 +2266,7 @@ const currentTimeExtension = (pi) => {
|
|
|
2200
2266
|
const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
|
|
2201
2267
|
const base = event.systemPrompt;
|
|
2202
2268
|
if (PI_DATE_LINE.test(base)) {
|
|
2203
|
-
log$
|
|
2269
|
+
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)");
|
|
2204
2270
|
return { systemPrompt: base.replace(PI_DATE_LINE, line) };
|
|
2205
2271
|
}
|
|
2206
2272
|
return { systemPrompt: `${base}\n${line}` };
|
|
@@ -2437,7 +2503,7 @@ function renderIndex(entries) {
|
|
|
2437
2503
|
}
|
|
2438
2504
|
//#endregion
|
|
2439
2505
|
//#region src/extensions/memory.ts
|
|
2440
|
-
const log$
|
|
2506
|
+
const log$7 = logger.child({ module: "memory-extension" });
|
|
2441
2507
|
/**
|
|
2442
2508
|
* The standing instructions for the memory system. Always injected (even with
|
|
2443
2509
|
* an empty `.memory/`) so the agent knows it can persist notes. `users/` is
|
|
@@ -2471,7 +2537,7 @@ const memoryExtension = (pi) => {
|
|
|
2471
2537
|
index
|
|
2472
2538
|
});
|
|
2473
2539
|
} catch (err) {
|
|
2474
|
-
log$
|
|
2540
|
+
log$7.warn({
|
|
2475
2541
|
err,
|
|
2476
2542
|
event: "memory_index_failed"
|
|
2477
2543
|
}, "memory index build failed; injecting instructions only");
|
|
@@ -2485,7 +2551,7 @@ const memoryExtension = (pi) => {
|
|
|
2485
2551
|
};
|
|
2486
2552
|
//#endregion
|
|
2487
2553
|
//#region src/extensions/platform-memory.ts
|
|
2488
|
-
const log$
|
|
2554
|
+
const log$6 = logger.child({ module: "platform-memory-extension" });
|
|
2489
2555
|
/**
|
|
2490
2556
|
* Resolve the human on this turn via the API, keyed by the message id.
|
|
2491
2557
|
* `/sandbox/channel-context` only returns a sender for a platform-known
|
|
@@ -2498,13 +2564,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
|
|
|
2498
2564
|
async function resolveTurnUser(messageId) {
|
|
2499
2565
|
const client = sandboxClient();
|
|
2500
2566
|
if (!client) {
|
|
2501
|
-
log$
|
|
2567
|
+
log$6.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
|
|
2502
2568
|
return null;
|
|
2503
2569
|
}
|
|
2504
2570
|
try {
|
|
2505
2571
|
const res = await client["channel-context"].$get({ query: { messageId } });
|
|
2506
2572
|
if (!res.ok) {
|
|
2507
|
-
log$
|
|
2573
|
+
log$6.warn({
|
|
2508
2574
|
event: "resolve_turn_user_failed",
|
|
2509
2575
|
status: res.status
|
|
2510
2576
|
}, "channel-context returned non-ok; withholding user memory");
|
|
@@ -2517,7 +2583,7 @@ async function resolveTurnUser(messageId) {
|
|
|
2517
2583
|
displayName: sender.displayName
|
|
2518
2584
|
};
|
|
2519
2585
|
} catch (err) {
|
|
2520
|
-
log$
|
|
2586
|
+
log$6.warn({
|
|
2521
2587
|
err,
|
|
2522
2588
|
event: "resolve_turn_user_failed"
|
|
2523
2589
|
}, "failed to resolve current user; withholding user memory");
|
|
@@ -2564,7 +2630,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2564
2630
|
user
|
|
2565
2631
|
});
|
|
2566
2632
|
} catch (err) {
|
|
2567
|
-
log$
|
|
2633
|
+
log$6.warn({
|
|
2568
2634
|
err,
|
|
2569
2635
|
event: "user_memory_index_failed"
|
|
2570
2636
|
}, "user memory index build failed; skipping injection");
|
|
@@ -2579,7 +2645,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2579
2645
|
}
|
|
2580
2646
|
//#endregion
|
|
2581
2647
|
//#region src/extensions/self-trace.ts
|
|
2582
|
-
const log$
|
|
2648
|
+
const log$5 = logger.child({ module: "self-trace-extension" });
|
|
2583
2649
|
/**
|
|
2584
2650
|
* Reports the agent's own execution as OpenTelemetry spans:
|
|
2585
2651
|
* agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
|
|
@@ -2604,7 +2670,7 @@ const selfTraceExtension = (pi) => {
|
|
|
2604
2670
|
sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
|
|
2605
2671
|
sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
|
|
2606
2672
|
const sc = sessionSpan.spanContext();
|
|
2607
|
-
log$
|
|
2673
|
+
log$5.info({
|
|
2608
2674
|
event: "self_trace_session_start",
|
|
2609
2675
|
trace_id: sc.traceId,
|
|
2610
2676
|
span_id: sc.spanId,
|
|
@@ -2716,13 +2782,13 @@ const selfTraceExtension = (pi) => {
|
|
|
2716
2782
|
* Lives in the harness package — soul.md is content from the agent's
|
|
2717
2783
|
* own git repo, not from the platform — so its handling stays here.
|
|
2718
2784
|
*/
|
|
2719
|
-
const log$
|
|
2785
|
+
const log$4 = logger.child({ module: "soul-extension" });
|
|
2720
2786
|
async function readSoul(cwd) {
|
|
2721
2787
|
try {
|
|
2722
2788
|
return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
|
|
2723
2789
|
} catch (err) {
|
|
2724
2790
|
if (err?.code === "ENOENT") return null;
|
|
2725
|
-
log$
|
|
2791
|
+
log$4.warn({
|
|
2726
2792
|
err,
|
|
2727
2793
|
event: "soul_read_failed"
|
|
2728
2794
|
}, "soul.md read failed");
|
|
@@ -2736,7 +2802,7 @@ function soulSection(cwd, soul) {
|
|
|
2736
2802
|
|
|
2737
2803
|
**\`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.
|
|
2738
2804
|
|
|
2739
|
-
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.
|
|
2805
|
+
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.
|
|
2740
2806
|
|
|
2741
2807
|
${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
|
|
2742
2808
|
}
|
|
@@ -2754,13 +2820,23 @@ const soulExtension = (pi) => {
|
|
|
2754
2820
|
};
|
|
2755
2821
|
//#endregion
|
|
2756
2822
|
//#region src/extensions/subagent/index.ts
|
|
2757
|
-
const log$
|
|
2823
|
+
const log$3 = logger.child({ module: "subagent-ext" });
|
|
2758
2824
|
const MAX_TASKS = 8;
|
|
2759
2825
|
const TaskItem = Type.Object({
|
|
2760
2826
|
task: Type.String({ description: "The task to delegate to a subagent run." }),
|
|
2827
|
+
title: Type.String({
|
|
2828
|
+
description: "A SHORT name for this task — 3-6 words, sentence case, no trailing period. This is what the person in the chat sees as the row for this subagent, so name the work, do not restate the prompt. Good: \"Audit the billing gate\", \"Compare competitor pricing\", \"Draft the migration\". Bad: \"You are looking at apps/anyone/web and should check every component…\".",
|
|
2829
|
+
maxLength: 120
|
|
2830
|
+
}),
|
|
2761
2831
|
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." })),
|
|
2762
|
-
model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on
|
|
2832
|
+
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." })),
|
|
2833
|
+
timeoutMinutes: Type.Optional(Type.Integer({
|
|
2834
|
+
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.",
|
|
2835
|
+
minimum: 1,
|
|
2836
|
+
maximum: 360
|
|
2837
|
+
}))
|
|
2763
2838
|
});
|
|
2839
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
|
|
2764
2840
|
const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
|
|
2765
2841
|
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.",
|
|
2766
2842
|
minItems: 1,
|
|
@@ -2775,7 +2851,9 @@ function buildTool(messageId) {
|
|
|
2775
2851
|
"Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
|
|
2776
2852
|
"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.",
|
|
2777
2853
|
"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.",
|
|
2778
|
-
"Pass tasks: [{ task, persona?, model? }]. 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
|
|
2854
|
+
"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.",
|
|
2855
|
+
"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.",
|
|
2856
|
+
"Steering: to add context, correct course, or answer a question a subagent needs mid-run, post to its conversation with `platform conversations post <conversationId> --message \"...\"`. If the subagent is still running, your message lands as a live steer picked up in that same turn; if it has gone idle, it queues as its next turn. This is the same primitive as any conversation message — there is no separate steer channel."
|
|
2779
2857
|
].join(" "),
|
|
2780
2858
|
promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
|
|
2781
2859
|
parameters: SubagentParams,
|
|
@@ -2791,29 +2869,41 @@ function buildTool(messageId) {
|
|
|
2791
2869
|
};
|
|
2792
2870
|
const spawnTasks = tasks.map((t) => ({
|
|
2793
2871
|
task: t.task,
|
|
2872
|
+
title: t.title ?? null,
|
|
2794
2873
|
persona: t.persona ?? null,
|
|
2795
|
-
model: t.model ?? null
|
|
2874
|
+
model: t.model ?? null,
|
|
2875
|
+
timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
|
|
2796
2876
|
}));
|
|
2797
2877
|
try {
|
|
2798
|
-
const
|
|
2878
|
+
const spawned = await postSubagentSpawn({
|
|
2799
2879
|
messageId,
|
|
2800
2880
|
tasks: spawnTasks
|
|
2801
2881
|
});
|
|
2802
|
-
|
|
2882
|
+
const { taskIds } = spawned;
|
|
2883
|
+
log$3.info({
|
|
2803
2884
|
event: "subagent_spawned",
|
|
2804
2885
|
count: taskIds.length
|
|
2805
2886
|
}, "subagent tasks queued");
|
|
2806
|
-
const
|
|
2887
|
+
const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
|
|
2888
|
+
const lines = taskIds.map((id, i) => {
|
|
2889
|
+
const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
|
|
2890
|
+
const conv = convByTask.get(id);
|
|
2891
|
+
return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
|
|
2892
|
+
}).join("\n");
|
|
2893
|
+
const peerHint = spawned.tasks.length ? "\nEach subagent runs on its own conversation (id shown per task above). To SEE what one is doing while it runs, read it with `platform conversations show <conversationId>`. To STEER one mid-run — add context, correct course, answer a question — post to its conversation with `platform conversations post <conversationId> --message \"...\"`; it lands as a live steer if the subagent is still running, or as its next turn if it has gone idle." : "";
|
|
2807
2894
|
return {
|
|
2808
2895
|
content: [{
|
|
2809
2896
|
type: "text",
|
|
2810
|
-
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}`
|
|
2897
|
+
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}`
|
|
2811
2898
|
}],
|
|
2812
|
-
details: {
|
|
2899
|
+
details: {
|
|
2900
|
+
taskIds,
|
|
2901
|
+
tasks: spawned.tasks
|
|
2902
|
+
}
|
|
2813
2903
|
};
|
|
2814
2904
|
} catch (err) {
|
|
2815
2905
|
const message = err instanceof Error ? err.message : String(err);
|
|
2816
|
-
log$
|
|
2906
|
+
log$3.warn({
|
|
2817
2907
|
err,
|
|
2818
2908
|
event: "subagent_spawn_failed"
|
|
2819
2909
|
}, "subagent spawn failed");
|
|
@@ -2830,31 +2920,249 @@ function buildTool(messageId) {
|
|
|
2830
2920
|
};
|
|
2831
2921
|
}
|
|
2832
2922
|
/**
|
|
2833
|
-
* Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
|
|
2834
2923
|
* The factory takes the session's channel context to resolve the originating
|
|
2835
2924
|
* messageId — the api links each spawned run to the conversation that message
|
|
2836
2925
|
* belongs to and rewakes it on completion (nothing about the parent is piped
|
|
2837
|
-
* from the sandbox beyond that id).
|
|
2926
|
+
* from the sandbox beyond that id). The tool is registered unconditionally at
|
|
2927
|
+
* session_start.
|
|
2838
2928
|
*/
|
|
2839
2929
|
function createSubagentExtension({ channelContext }) {
|
|
2840
2930
|
return (pi) => {
|
|
2841
2931
|
const messageId = extractMessageId(channelContext);
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
if (
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
}
|
|
2932
|
+
let registered = false;
|
|
2933
|
+
const registerOnce = () => {
|
|
2934
|
+
if (registered) return;
|
|
2935
|
+
registered = true;
|
|
2936
|
+
pi.registerTool(buildTool(messageId));
|
|
2937
|
+
log$3.info({ event: "subagent_enabled" }, "subagent tool registered");
|
|
2938
|
+
};
|
|
2939
|
+
pi.on("session_start", () => {
|
|
2940
|
+
registerOnce();
|
|
2848
2941
|
});
|
|
2849
2942
|
};
|
|
2850
2943
|
}
|
|
2851
2944
|
//#endregion
|
|
2945
|
+
//#region src/extensions/resource-pressure-warning.ts
|
|
2946
|
+
/**
|
|
2947
|
+
* Mid-run resource-pressure warning to the agent.
|
|
2948
|
+
*
|
|
2949
|
+
* The sandbox already detects pressure — the boot scripts cap the
|
|
2950
|
+
* user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
|
|
2951
|
+
* edges for memory and disk — but nothing told the *agent*, so a turn burned
|
|
2952
|
+
* straight to the OOM kill (or a full disk) and only learned about it from
|
|
2953
|
+
* the post-mortem notice. This extension closes that gap in-process: while a
|
|
2954
|
+
* turn is active it polls the agent cgroup and the root filesystem and, the
|
|
2955
|
+
* first time usage crosses a warn threshold, folds a system notification into
|
|
2956
|
+
* the open turn so the agent can checkpoint, shed work (constrain
|
|
2957
|
+
* parallelism, kill a background hog, clean scratch space), or request a
|
|
2958
|
+
* bigger tier BEFORE the kill.
|
|
2959
|
+
*
|
|
2960
|
+
* The notification is triggered by the two conditions that actually kill
|
|
2961
|
+
* work — memory near the cgroup hard cap, disk near full — and reports a
|
|
2962
|
+
* snapshot of all the relevant stats (memory, CPU utilization, disk) so the
|
|
2963
|
+
* agent can tell which resource is the problem and how much headroom the
|
|
2964
|
+
* others have.
|
|
2965
|
+
*
|
|
2966
|
+
* Edge-triggered, once per trigger per turn: the fired flags reset on
|
|
2967
|
+
* agent_start, so a turn that rides a threshold gets one warning per
|
|
2968
|
+
* resource, not a stream. Polling only runs while the agent is active — an
|
|
2969
|
+
* idle sandbox's resource usage is not the agent's problem and there is no
|
|
2970
|
+
* open turn to deliver into anyway.
|
|
2971
|
+
*
|
|
2972
|
+
* Best-effort throughout: any read failure (cgroup absent, controller not
|
|
2973
|
+
* delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
|
|
2974
|
+
* stat and the extension warns on what it can see — it must never break a
|
|
2975
|
+
* turn over an observability feature.
|
|
2976
|
+
*/
|
|
2977
|
+
const execFileAsync = promisify(execFile);
|
|
2978
|
+
const log$2 = logger.child({ module: "resource-pressure-warning" });
|
|
2979
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
2980
|
+
function envOverride(name) {
|
|
2981
|
+
for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
|
|
2982
|
+
const value = process.env[`${prefix}${name}`];
|
|
2983
|
+
if (value != null && value !== "") return value;
|
|
2984
|
+
}
|
|
2985
|
+
return null;
|
|
2986
|
+
}
|
|
2987
|
+
function cgroupDir() {
|
|
2988
|
+
return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
|
|
2989
|
+
}
|
|
2990
|
+
function diskRoot() {
|
|
2991
|
+
return envOverride("DISK_ROOT") ?? "/";
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Read a cgroup v2 scalar file. Returns a number, or null for "max"
|
|
2995
|
+
* (uncapped), an empty/absent file, or any read/parse error — an uncapped or
|
|
2996
|
+
* unreadable limit means there is nothing meaningful to warn against.
|
|
2997
|
+
*/
|
|
2998
|
+
async function readScalar(file) {
|
|
2999
|
+
try {
|
|
3000
|
+
const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
|
|
3001
|
+
if (raw === "" || raw === "max") return null;
|
|
3002
|
+
const n = Number(raw);
|
|
3003
|
+
return Number.isFinite(n) ? n : null;
|
|
3004
|
+
} catch (_error) {
|
|
3005
|
+
return null;
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
3009
|
+
* Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
|
|
3010
|
+
* cpu.stat) and return the counter for `key`, or null when absent.
|
|
3011
|
+
*/
|
|
3012
|
+
async function readKeyedCounter(file, key) {
|
|
3013
|
+
try {
|
|
3014
|
+
const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
|
|
3015
|
+
for (const line of raw.split("\n")) {
|
|
3016
|
+
const [k, v] = line.trim().split(/\s+/);
|
|
3017
|
+
if (k === key) {
|
|
3018
|
+
const n = Number(v);
|
|
3019
|
+
return Number.isFinite(n) ? n : null;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
return null;
|
|
3023
|
+
} catch (_error) {
|
|
3024
|
+
return null;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Live memory usage as an integer percent of the hard cap, or null when
|
|
3029
|
+
* either side is unreadable/uncapped. Exported for tests.
|
|
3030
|
+
*/
|
|
3031
|
+
async function readMemUsePct() {
|
|
3032
|
+
const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
|
|
3033
|
+
if (current === null || max === null || max <= 0) return null;
|
|
3034
|
+
return {
|
|
3035
|
+
pct: Math.floor(current / max * 100),
|
|
3036
|
+
currentBytes: current,
|
|
3037
|
+
maxBytes: max
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
/**
|
|
3041
|
+
* Root filesystem used% (df -P Capacity column), or null on any failure.
|
|
3042
|
+
* Exported for tests.
|
|
3043
|
+
*/
|
|
3044
|
+
async function readDiskUsePct() {
|
|
3045
|
+
try {
|
|
3046
|
+
const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
|
|
3047
|
+
const dataRow = stdout.trim().split("\n")[1];
|
|
3048
|
+
if (dataRow == null) return null;
|
|
3049
|
+
const capacity = dataRow.trim().split(/\s+/)[4];
|
|
3050
|
+
if (capacity == null) return null;
|
|
3051
|
+
const pct = Number(capacity.replace("%", ""));
|
|
3052
|
+
return Number.isFinite(pct) ? pct : null;
|
|
3053
|
+
} catch (_error) {
|
|
3054
|
+
return null;
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* CPU utilization sampler. cgroup v2 exposes cumulative CPU time
|
|
3059
|
+
* (cpu.stat usage_usec); utilization is the delta between two samples over
|
|
3060
|
+
* the wall time between them, normalized by core count. The first call after
|
|
3061
|
+
* construction has no previous sample and returns null.
|
|
3062
|
+
*/
|
|
3063
|
+
function createCpuSampler() {
|
|
3064
|
+
let prevUsageUsec = null;
|
|
3065
|
+
let prevAtMs = null;
|
|
3066
|
+
return async () => {
|
|
3067
|
+
const usage = await readKeyedCounter("cpu.stat", "usage_usec");
|
|
3068
|
+
const now = Date.now();
|
|
3069
|
+
const prev = prevUsageUsec;
|
|
3070
|
+
const prevAt = prevAtMs;
|
|
3071
|
+
prevUsageUsec = usage;
|
|
3072
|
+
prevAtMs = now;
|
|
3073
|
+
if (usage === null || prev === null || prevAt === null) return null;
|
|
3074
|
+
const wallUsec = (now - prevAt) * 1e3;
|
|
3075
|
+
if (wallUsec <= 0) return null;
|
|
3076
|
+
const cores = availableParallelism();
|
|
3077
|
+
const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
|
|
3078
|
+
return Math.max(0, Math.min(100, pct));
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
function fmtMb(bytes) {
|
|
3082
|
+
return Math.round(bytes / 1024 / 1024);
|
|
3083
|
+
}
|
|
3084
|
+
/** The model-facing warning text. Exported for tests. */
|
|
3085
|
+
function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
|
|
3086
|
+
const stats = [];
|
|
3087
|
+
if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
|
|
3088
|
+
if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
|
|
3089
|
+
if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
|
|
3090
|
+
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.`;
|
|
3091
|
+
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.";
|
|
3092
|
+
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>`;
|
|
3093
|
+
}
|
|
3094
|
+
const resourcePressureWarningExtension = (pi) => {
|
|
3095
|
+
let agentActive = false;
|
|
3096
|
+
let warnedMemThisTurn = false;
|
|
3097
|
+
let warnedDiskThisTurn = false;
|
|
3098
|
+
let timer = null;
|
|
3099
|
+
const sampleCpu = createCpuSampler();
|
|
3100
|
+
async function checkOnce() {
|
|
3101
|
+
if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
|
|
3102
|
+
const [mem, cpuPct, diskPct] = await Promise.all([
|
|
3103
|
+
readMemUsePct(),
|
|
3104
|
+
sampleCpu(),
|
|
3105
|
+
readDiskUsePct()
|
|
3106
|
+
]);
|
|
3107
|
+
let trigger = null;
|
|
3108
|
+
if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
|
|
3109
|
+
trigger = "memory";
|
|
3110
|
+
warnedMemThisTurn = true;
|
|
3111
|
+
} else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
|
|
3112
|
+
trigger = "disk";
|
|
3113
|
+
warnedDiskThisTurn = true;
|
|
3114
|
+
}
|
|
3115
|
+
if (trigger === null) return;
|
|
3116
|
+
log$2.warn({
|
|
3117
|
+
trigger,
|
|
3118
|
+
mem,
|
|
3119
|
+
cpuPct,
|
|
3120
|
+
diskPct
|
|
3121
|
+
}, "resource pressure warning delivered to agent");
|
|
3122
|
+
await pi.sendMessage({
|
|
3123
|
+
customType: "anyone-resource-pressure-warning",
|
|
3124
|
+
content: resourcePressureWarningText(trigger, {
|
|
3125
|
+
mem,
|
|
3126
|
+
cpuPct,
|
|
3127
|
+
diskPct
|
|
3128
|
+
}),
|
|
3129
|
+
display: false
|
|
3130
|
+
}, {
|
|
3131
|
+
triggerTurn: true,
|
|
3132
|
+
deliverAs: "followUp"
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
pi.on("agent_start", async () => {
|
|
3136
|
+
agentActive = true;
|
|
3137
|
+
warnedMemThisTurn = false;
|
|
3138
|
+
warnedDiskThisTurn = false;
|
|
3139
|
+
if (!timer) {
|
|
3140
|
+
timer = setInterval(() => {
|
|
3141
|
+
checkOnce().catch((err) => {
|
|
3142
|
+
log$2.error({ err }, "resource pressure check failed");
|
|
3143
|
+
});
|
|
3144
|
+
}, POLL_INTERVAL_MS);
|
|
3145
|
+
timer.unref?.();
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
3148
|
+
pi.on("agent_end", async () => {
|
|
3149
|
+
agentActive = false;
|
|
3150
|
+
if (timer) {
|
|
3151
|
+
clearInterval(timer);
|
|
3152
|
+
timer = null;
|
|
3153
|
+
}
|
|
3154
|
+
});
|
|
3155
|
+
};
|
|
3156
|
+
//#endregion
|
|
2852
3157
|
//#region src/extensions/tool-call-env.ts
|
|
2853
3158
|
const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
|
|
3159
|
+
function shellQuoteValue(value) {
|
|
3160
|
+
return quote([value]);
|
|
3161
|
+
}
|
|
2854
3162
|
function withToolCallId({ command, toolCallId }) {
|
|
2855
|
-
return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
|
|
3163
|
+
return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
|
|
2856
3164
|
}
|
|
2857
|
-
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
|
|
3165
|
+
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
|
|
2858
3166
|
function stripPlatformExportsForDisplay(command) {
|
|
2859
3167
|
let c = command;
|
|
2860
3168
|
let m;
|
|
@@ -3619,7 +3927,8 @@ function platformExtensions({ sessionId, channelContext }) {
|
|
|
3619
3927
|
selfTraceExtension,
|
|
3620
3928
|
createBackgroundTasksExtension({ channelContext }),
|
|
3621
3929
|
createSubagentExtension({ channelContext }),
|
|
3622
|
-
createContextManagementExtension()
|
|
3930
|
+
createContextManagementExtension(),
|
|
3931
|
+
resourcePressureWarningExtension
|
|
3623
3932
|
];
|
|
3624
3933
|
}
|
|
3625
3934
|
//#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.900",
|
|
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"
|