@skydiveai/pi-extensions 0.1.0-beta.160 → 0.1.0-beta.1602
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 +999 -220
- package/package.json +2 -9
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
|
|
3
3
|
import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
|
|
4
4
|
import { buildAgentCard, chainMiddleware, composeHandlers, createAgentExecutor, createProtocolHandlers, getCurrentTraceparent, logger, mountAt, requestHeaders, requestUrl, webHandlerToMiddleware } from "@skydiveai/pi-server";
|
|
5
|
-
import { mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
@@ -19,6 +19,9 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
|
19
19
|
import { Resource } from "@opentelemetry/resources";
|
|
20
20
|
import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
21
21
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { availableParallelism } from "node:os";
|
|
24
|
+
import { promisify } from "node:util";
|
|
22
25
|
import { hc } from "hono/client";
|
|
23
26
|
import { parse } from "yaml";
|
|
24
27
|
import { quote } from "shell-quote";
|
|
@@ -256,7 +259,7 @@ function createHealthHandler({ metadata }) {
|
|
|
256
259
|
* read on the hot path before every LLM call), it falls back to the default
|
|
257
260
|
* for that knob and logs once.
|
|
258
261
|
*/
|
|
259
|
-
const log$
|
|
262
|
+
const log$15 = logger.child({ module: "context-management-config" });
|
|
260
263
|
const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
|
|
261
264
|
enabled: false,
|
|
262
265
|
perResultMaxBytes: 16 * 1024,
|
|
@@ -304,7 +307,7 @@ function resolveContextManagementConfig(env = process.env) {
|
|
|
304
307
|
maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
|
|
305
308
|
});
|
|
306
309
|
if (!parsed.success) {
|
|
307
|
-
log$
|
|
310
|
+
log$15.warn({
|
|
308
311
|
event: "context_management_config_invalid",
|
|
309
312
|
err: parsed.error
|
|
310
313
|
}, "falling back to default context-management config");
|
|
@@ -438,7 +441,7 @@ const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task i
|
|
|
438
441
|
* or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
|
|
439
442
|
* `tools/_example.ts` documents the shape without registering.
|
|
440
443
|
*/
|
|
441
|
-
const log$
|
|
444
|
+
const log$14 = logger.child({ module: "local-tools-extension" });
|
|
442
445
|
const TOOLS_DIRNAME = "tools";
|
|
443
446
|
const fileState = /* @__PURE__ */ new Map();
|
|
444
447
|
let pendingLocalToolsUpdate = null;
|
|
@@ -582,7 +585,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
|
|
|
582
585
|
dir
|
|
583
586
|
});
|
|
584
587
|
if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
|
|
585
|
-
log$
|
|
588
|
+
log$14.info({
|
|
586
589
|
event: "local_tools_reconcile",
|
|
587
590
|
reason,
|
|
588
591
|
total_tools: summary.totalTools,
|
|
@@ -604,7 +607,7 @@ const localToolsExtension = (pi) => {
|
|
|
604
607
|
reason: "session_start"
|
|
605
608
|
});
|
|
606
609
|
} catch (err) {
|
|
607
|
-
log$
|
|
610
|
+
log$14.error({
|
|
608
611
|
err,
|
|
609
612
|
event: "local_tools_reconcile_failed"
|
|
610
613
|
}, "local tools reconcile failed");
|
|
@@ -616,7 +619,7 @@ const localToolsExtension = (pi) => {
|
|
|
616
619
|
try {
|
|
617
620
|
current = await listToolFiles(dir);
|
|
618
621
|
} catch (err) {
|
|
619
|
-
log$
|
|
622
|
+
log$14.warn({
|
|
620
623
|
err,
|
|
621
624
|
event: "local_tools_listing_failed"
|
|
622
625
|
}, "tools/ listing failed");
|
|
@@ -638,7 +641,7 @@ const localToolsExtension = (pi) => {
|
|
|
638
641
|
reason: "auto_reload"
|
|
639
642
|
});
|
|
640
643
|
} catch (err) {
|
|
641
|
-
log$
|
|
644
|
+
log$14.error({
|
|
642
645
|
err,
|
|
643
646
|
event: "local_tools_auto_reload_failed"
|
|
644
647
|
}, "auto-reload after tools/ change failed");
|
|
@@ -692,6 +695,19 @@ const STDERR_BUFFER_BYTES = 4096;
|
|
|
692
695
|
* indistinguishable from any other transport problem. Walk the cause
|
|
693
696
|
* chain so the agent sees the real underlying error.
|
|
694
697
|
*/
|
|
698
|
+
/**
|
|
699
|
+
* True when an error from an http MCP transport (connect, listTools, or a tool
|
|
700
|
+
* call) is an authentication failure. With no authProvider configured the SDK
|
|
701
|
+
* surfaces a 401 as `StreamableHTTPError(401)`; older paths translate it to
|
|
702
|
+
* `UnauthorizedError`. A dead/expired OAuth token (the proxy can no longer
|
|
703
|
+
* mint one) shows up here on the NEXT request against a previously-connected
|
|
704
|
+
* client — not just at connect — so reconcile must re-classify such a failure
|
|
705
|
+
* as `pending_auth` instead of a generic `failed`, keeping the "waiting on
|
|
706
|
+
* auth" report consistent with `platform auth`.
|
|
707
|
+
*/
|
|
708
|
+
function isUnauthorizedError(err) {
|
|
709
|
+
return err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401;
|
|
710
|
+
}
|
|
695
711
|
function formatError(err) {
|
|
696
712
|
if (!(err instanceof Error)) return String(err);
|
|
697
713
|
const parts = [err.message];
|
|
@@ -713,7 +729,7 @@ async function connectHttp(_id, config, client) {
|
|
|
713
729
|
stderr: null
|
|
714
730
|
};
|
|
715
731
|
} catch (err) {
|
|
716
|
-
if (err
|
|
732
|
+
if (isUnauthorizedError(err)) return {
|
|
717
733
|
status: "pending_auth",
|
|
718
734
|
client,
|
|
719
735
|
stderr: "",
|
|
@@ -863,7 +879,7 @@ async function loadMcpConfig(path) {
|
|
|
863
879
|
* Clients are keyed by JSON-stringified config and reused across
|
|
864
880
|
* reloads — only changed configs reconnect.
|
|
865
881
|
*/
|
|
866
|
-
const log$
|
|
882
|
+
const log$13 = logger.child({ module: "mcp-extension" });
|
|
867
883
|
async function closeConnected(connected) {
|
|
868
884
|
try {
|
|
869
885
|
await connected.client.close();
|
|
@@ -1190,6 +1206,64 @@ var McpExtension = class {
|
|
|
1190
1206
|
try {
|
|
1191
1207
|
mcpTools = (await connected.client.listTools()).tools;
|
|
1192
1208
|
} catch (err) {
|
|
1209
|
+
if (isUnauthorizedError(err) && serverConfig.transport === "http") {
|
|
1210
|
+
await closeConnected(connected);
|
|
1211
|
+
const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
|
|
1212
|
+
if (retry.status === "pending_auth") return {
|
|
1213
|
+
id,
|
|
1214
|
+
store: {
|
|
1215
|
+
client: retry.client,
|
|
1216
|
+
configKey,
|
|
1217
|
+
status: "pending_auth",
|
|
1218
|
+
stderrBuffer: null,
|
|
1219
|
+
cliHint: retry.cliHint
|
|
1220
|
+
},
|
|
1221
|
+
serverStatus: {
|
|
1222
|
+
status: "pending_auth",
|
|
1223
|
+
stderr: "",
|
|
1224
|
+
cliHint: retry.cliHint
|
|
1225
|
+
},
|
|
1226
|
+
change: null,
|
|
1227
|
+
error: null,
|
|
1228
|
+
tools: null
|
|
1229
|
+
};
|
|
1230
|
+
if (retry.status === "failed") return {
|
|
1231
|
+
id,
|
|
1232
|
+
store: null,
|
|
1233
|
+
serverStatus: {
|
|
1234
|
+
status: "failed",
|
|
1235
|
+
error: retry.error,
|
|
1236
|
+
stderr: retry.stderr
|
|
1237
|
+
},
|
|
1238
|
+
change: null,
|
|
1239
|
+
error: {
|
|
1240
|
+
serverId: id,
|
|
1241
|
+
message: retry.error
|
|
1242
|
+
},
|
|
1243
|
+
tools: null
|
|
1244
|
+
};
|
|
1245
|
+
if (retry.status === "connected") {
|
|
1246
|
+
connected = {
|
|
1247
|
+
client: retry.client,
|
|
1248
|
+
configKey,
|
|
1249
|
+
status: "connected",
|
|
1250
|
+
stderrBuffer: retry.stderr,
|
|
1251
|
+
cliHint: null
|
|
1252
|
+
};
|
|
1253
|
+
mcpTools = (await connected.client.listTools()).tools;
|
|
1254
|
+
return {
|
|
1255
|
+
id,
|
|
1256
|
+
store: connected,
|
|
1257
|
+
serverStatus: { status: "connected" },
|
|
1258
|
+
change: action === "reused" ? "refreshed" : action,
|
|
1259
|
+
error: null,
|
|
1260
|
+
tools: {
|
|
1261
|
+
client: connected.client,
|
|
1262
|
+
list: mcpTools
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1193
1267
|
const message = err instanceof Error ? err.message : String(err);
|
|
1194
1268
|
const stderr = connected.stderrBuffer?.read() ?? "";
|
|
1195
1269
|
return {
|
|
@@ -1227,7 +1301,7 @@ var McpExtension = class {
|
|
|
1227
1301
|
});
|
|
1228
1302
|
this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
|
|
1229
1303
|
if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
|
|
1230
|
-
log$
|
|
1304
|
+
log$13.info({
|
|
1231
1305
|
event: "mcp_reconcile",
|
|
1232
1306
|
reason,
|
|
1233
1307
|
total_tools: summary.totalTools,
|
|
@@ -1250,7 +1324,7 @@ var McpExtension = class {
|
|
|
1250
1324
|
reason: "session_start"
|
|
1251
1325
|
});
|
|
1252
1326
|
} catch (err) {
|
|
1253
|
-
log$
|
|
1327
|
+
log$13.error({
|
|
1254
1328
|
err,
|
|
1255
1329
|
event: "mcp_reconcile_failed"
|
|
1256
1330
|
}, "MCP reconcile failed");
|
|
@@ -1262,7 +1336,7 @@ var McpExtension = class {
|
|
|
1262
1336
|
try {
|
|
1263
1337
|
mtime = await readConfigMtimeMs(configPath);
|
|
1264
1338
|
} catch (err) {
|
|
1265
|
-
log$
|
|
1339
|
+
log$13.warn({
|
|
1266
1340
|
err,
|
|
1267
1341
|
event: "mcp_mtime_check_failed"
|
|
1268
1342
|
}, "mtime check on mcp.config.json failed");
|
|
@@ -1276,7 +1350,7 @@ var McpExtension = class {
|
|
|
1276
1350
|
reason: "auto_reload"
|
|
1277
1351
|
});
|
|
1278
1352
|
} catch (err) {
|
|
1279
|
-
log$
|
|
1353
|
+
log$13.error({
|
|
1280
1354
|
err,
|
|
1281
1355
|
event: "mcp_auto_reload_failed"
|
|
1282
1356
|
}, "auto-reload after mcp.config.json change failed");
|
|
@@ -1639,16 +1713,380 @@ const bashDefaultTimeoutExtension = (pi) => {
|
|
|
1639
1713
|
});
|
|
1640
1714
|
};
|
|
1641
1715
|
//#endregion
|
|
1716
|
+
//#region src/extensions/resource-pressure-warning.ts
|
|
1717
|
+
/**
|
|
1718
|
+
* Mid-run resource-pressure warning to the agent.
|
|
1719
|
+
*
|
|
1720
|
+
* The sandbox already detects pressure — the boot scripts cap the
|
|
1721
|
+
* user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
|
|
1722
|
+
* edges for memory and disk — but nothing told the *agent*, so a turn burned
|
|
1723
|
+
* straight to the OOM kill (or a full disk) and only learned about it from
|
|
1724
|
+
* the post-mortem notice. This extension closes that gap in-process: while a
|
|
1725
|
+
* turn is active it polls the agent cgroup and the root filesystem and, the
|
|
1726
|
+
* first time usage crosses a warn threshold, folds a system notification into
|
|
1727
|
+
* the open turn so the agent can checkpoint, shed work (constrain
|
|
1728
|
+
* parallelism, kill a background hog, clean scratch space), or request a
|
|
1729
|
+
* bigger tier BEFORE the kill.
|
|
1730
|
+
*
|
|
1731
|
+
* The notification is triggered by the two conditions that actually kill
|
|
1732
|
+
* work — memory near the cgroup hard cap, disk near full — and reports a
|
|
1733
|
+
* snapshot of all the relevant stats (memory, CPU utilization, disk) so the
|
|
1734
|
+
* agent can tell which resource is the problem and how much headroom the
|
|
1735
|
+
* others have.
|
|
1736
|
+
*
|
|
1737
|
+
* Edge-triggered, once per trigger per turn: the fired flags reset on
|
|
1738
|
+
* agent_start, so a turn that rides a threshold gets one warning per
|
|
1739
|
+
* resource, not a stream. Polling only runs while the agent is active — an
|
|
1740
|
+
* idle sandbox's resource usage is not the agent's problem and there is no
|
|
1741
|
+
* open turn to deliver into anyway.
|
|
1742
|
+
*
|
|
1743
|
+
* Best-effort throughout: any read failure (cgroup absent, controller not
|
|
1744
|
+
* delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
|
|
1745
|
+
* stat and the extension warns on what it can see — it must never break a
|
|
1746
|
+
* turn over an observability feature.
|
|
1747
|
+
*/
|
|
1748
|
+
const execFileAsync = promisify(execFile);
|
|
1749
|
+
const log$12 = logger.child({ module: "resource-pressure-warning" });
|
|
1750
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
1751
|
+
function envOverride(name) {
|
|
1752
|
+
for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
|
|
1753
|
+
const value = process.env[`${prefix}${name}`];
|
|
1754
|
+
if (value != null && value !== "") return value;
|
|
1755
|
+
}
|
|
1756
|
+
return null;
|
|
1757
|
+
}
|
|
1758
|
+
function cgroupDir() {
|
|
1759
|
+
return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
|
|
1760
|
+
}
|
|
1761
|
+
function diskRoot() {
|
|
1762
|
+
return envOverride("DISK_ROOT") ?? "/";
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Read a cgroup v2 scalar file. Returns a number, or null for "max"
|
|
1766
|
+
* (uncapped), an empty/absent file, or any read/parse error — an uncapped or
|
|
1767
|
+
* unreadable limit means there is nothing meaningful to warn against.
|
|
1768
|
+
*/
|
|
1769
|
+
async function readScalar(file) {
|
|
1770
|
+
try {
|
|
1771
|
+
const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
|
|
1772
|
+
if (raw === "" || raw === "max") return null;
|
|
1773
|
+
const n = Number(raw);
|
|
1774
|
+
return Number.isFinite(n) ? n : null;
|
|
1775
|
+
} catch (_error) {
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
|
|
1781
|
+
* cpu.stat) and return the counter for `key`, or null when absent.
|
|
1782
|
+
*/
|
|
1783
|
+
async function readKeyedCounter(file, key) {
|
|
1784
|
+
try {
|
|
1785
|
+
const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
|
|
1786
|
+
for (const line of raw.split("\n")) {
|
|
1787
|
+
const [k, v] = line.trim().split(/\s+/);
|
|
1788
|
+
if (k === key) {
|
|
1789
|
+
const n = Number(v);
|
|
1790
|
+
return Number.isFinite(n) ? n : null;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return null;
|
|
1794
|
+
} catch (_error) {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Live memory usage as an integer percent of the hard cap, or null when
|
|
1800
|
+
* either side is unreadable/uncapped. Exported for tests.
|
|
1801
|
+
*/
|
|
1802
|
+
async function readMemUsePct() {
|
|
1803
|
+
const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
|
|
1804
|
+
if (current === null || max === null || max <= 0) return null;
|
|
1805
|
+
return {
|
|
1806
|
+
pct: Math.floor(current / max * 100),
|
|
1807
|
+
currentBytes: current,
|
|
1808
|
+
maxBytes: max
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Root filesystem used% (df -P Capacity column), or null on any failure.
|
|
1813
|
+
* Exported for tests.
|
|
1814
|
+
*/
|
|
1815
|
+
async function readDiskUsePct() {
|
|
1816
|
+
try {
|
|
1817
|
+
const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
|
|
1818
|
+
const dataRow = stdout.trim().split("\n")[1];
|
|
1819
|
+
if (dataRow == null) return null;
|
|
1820
|
+
const capacity = dataRow.trim().split(/\s+/)[4];
|
|
1821
|
+
if (capacity == null) return null;
|
|
1822
|
+
const pct = Number(capacity.replace("%", ""));
|
|
1823
|
+
return Number.isFinite(pct) ? pct : null;
|
|
1824
|
+
} catch (_error) {
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* CPU utilization sampler. cgroup v2 exposes cumulative CPU time
|
|
1830
|
+
* (cpu.stat usage_usec); utilization is the delta between two samples over
|
|
1831
|
+
* the wall time between them, normalized by core count. The first call after
|
|
1832
|
+
* construction has no previous sample and returns null.
|
|
1833
|
+
*/
|
|
1834
|
+
function createCpuSampler() {
|
|
1835
|
+
let prevUsageUsec = null;
|
|
1836
|
+
let prevAtMs = null;
|
|
1837
|
+
return async () => {
|
|
1838
|
+
const usage = await readKeyedCounter("cpu.stat", "usage_usec");
|
|
1839
|
+
const now = Date.now();
|
|
1840
|
+
const prev = prevUsageUsec;
|
|
1841
|
+
const prevAt = prevAtMs;
|
|
1842
|
+
prevUsageUsec = usage;
|
|
1843
|
+
prevAtMs = now;
|
|
1844
|
+
if (usage === null || prev === null || prevAt === null) return null;
|
|
1845
|
+
const wallUsec = (now - prevAt) * 1e3;
|
|
1846
|
+
if (wallUsec <= 0) return null;
|
|
1847
|
+
const cores = availableParallelism();
|
|
1848
|
+
const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
|
|
1849
|
+
return Math.max(0, Math.min(100, pct));
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
function fmtMb(bytes) {
|
|
1853
|
+
return Math.round(bytes / 1024 / 1024);
|
|
1854
|
+
}
|
|
1855
|
+
/** The model-facing warning text. Exported for tests. */
|
|
1856
|
+
function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
|
|
1857
|
+
const stats = [];
|
|
1858
|
+
if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
|
|
1859
|
+
if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
|
|
1860
|
+
if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
|
|
1861
|
+
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.`;
|
|
1862
|
+
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.";
|
|
1863
|
+
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>`;
|
|
1864
|
+
}
|
|
1865
|
+
const resourcePressureWarningExtension = (pi) => {
|
|
1866
|
+
let agentActive = false;
|
|
1867
|
+
let warnedMemThisTurn = false;
|
|
1868
|
+
let warnedDiskThisTurn = false;
|
|
1869
|
+
let timer = null;
|
|
1870
|
+
const sampleCpu = createCpuSampler();
|
|
1871
|
+
async function checkOnce() {
|
|
1872
|
+
if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
|
|
1873
|
+
const [mem, cpuPct, diskPct] = await Promise.all([
|
|
1874
|
+
readMemUsePct(),
|
|
1875
|
+
sampleCpu(),
|
|
1876
|
+
readDiskUsePct()
|
|
1877
|
+
]);
|
|
1878
|
+
let trigger = null;
|
|
1879
|
+
if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
|
|
1880
|
+
trigger = "memory";
|
|
1881
|
+
warnedMemThisTurn = true;
|
|
1882
|
+
} else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
|
|
1883
|
+
trigger = "disk";
|
|
1884
|
+
warnedDiskThisTurn = true;
|
|
1885
|
+
}
|
|
1886
|
+
if (trigger === null) return;
|
|
1887
|
+
log$12.warn({
|
|
1888
|
+
trigger,
|
|
1889
|
+
mem,
|
|
1890
|
+
cpuPct,
|
|
1891
|
+
diskPct
|
|
1892
|
+
}, "resource pressure warning delivered to agent");
|
|
1893
|
+
await pi.sendMessage({
|
|
1894
|
+
customType: "anyone-resource-pressure-warning",
|
|
1895
|
+
content: resourcePressureWarningText(trigger, {
|
|
1896
|
+
mem,
|
|
1897
|
+
cpuPct,
|
|
1898
|
+
diskPct
|
|
1899
|
+
}),
|
|
1900
|
+
display: false
|
|
1901
|
+
}, {
|
|
1902
|
+
triggerTurn: true,
|
|
1903
|
+
deliverAs: "followUp"
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
pi.on("agent_start", async () => {
|
|
1907
|
+
agentActive = true;
|
|
1908
|
+
warnedMemThisTurn = false;
|
|
1909
|
+
warnedDiskThisTurn = false;
|
|
1910
|
+
if (!timer) {
|
|
1911
|
+
timer = setInterval(() => {
|
|
1912
|
+
checkOnce().catch((err) => {
|
|
1913
|
+
log$12.error({ err }, "resource pressure check failed");
|
|
1914
|
+
});
|
|
1915
|
+
}, POLL_INTERVAL_MS);
|
|
1916
|
+
timer.unref?.();
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
pi.on("agent_end", async () => {
|
|
1920
|
+
agentActive = false;
|
|
1921
|
+
if (timer) {
|
|
1922
|
+
clearInterval(timer);
|
|
1923
|
+
timer = null;
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
};
|
|
1927
|
+
//#endregion
|
|
1928
|
+
//#region src/extensions/disk-guard.ts
|
|
1929
|
+
const log$11 = logger.child({ module: "disk-guard" });
|
|
1930
|
+
/**
|
|
1931
|
+
* In-band bypass. The guard is a safety net, not a jail: when the agent knows
|
|
1932
|
+
* a flagged command is genuinely safe (writing to a different mount, a tiny
|
|
1933
|
+
* bounded download, a delete-then-clone one-liner, an emergency it accepts the
|
|
1934
|
+
* risk on) it can force the command through by appending this marker as a
|
|
1935
|
+
* trailing shell comment. Kept as a comment so it never changes what the
|
|
1936
|
+
* command does, and matched case-insensitively with flexible spacing so the
|
|
1937
|
+
* agent doesn't have to reproduce it byte-for-byte.
|
|
1938
|
+
*/
|
|
1939
|
+
const BYPASS_MARKER = /#\s*disk-guard:\s*allow\b/i;
|
|
1940
|
+
/** The exact marker text the block message tells the agent to append. */
|
|
1941
|
+
const BYPASS_HINT = "# disk-guard: allow";
|
|
1942
|
+
/**
|
|
1943
|
+
* Harness-level kill switch: set DISK_GUARD_DISABLE=1 to turn the guard off
|
|
1944
|
+
* entirely. This is the "I own my harness, let me opt out" knob — an agent
|
|
1945
|
+
* that boots its own harness can disable the guard for its whole process
|
|
1946
|
+
* without a code roll, and it's also the fleet-wide escape hatch if the
|
|
1947
|
+
* classifier ever misfires and blocks real work. The bare name is honored
|
|
1948
|
+
* first; the SKYDIVE_/ANYONE_ prefixes are accepted too for consistency with
|
|
1949
|
+
* the other env overrides. Empty/unset/"0"/"false" leave the guard on.
|
|
1950
|
+
*/
|
|
1951
|
+
function guardDisabledByEnv() {
|
|
1952
|
+
for (const name of [
|
|
1953
|
+
"DISK_GUARD_DISABLE",
|
|
1954
|
+
"SKYDIVE_DISK_GUARD_DISABLE",
|
|
1955
|
+
"ANYONE_DISK_GUARD_DISABLE"
|
|
1956
|
+
]) {
|
|
1957
|
+
const value = process.env[name];
|
|
1958
|
+
if (value != null && value !== "" && value !== "0" && value !== "false") return true;
|
|
1959
|
+
}
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
/** True when the command carries the in-band bypass marker. */
|
|
1963
|
+
function hasBypassMarker(command) {
|
|
1964
|
+
return BYPASS_MARKER.test(command);
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Commands that reclaim space or merely inspect it. If any of these verbs
|
|
1968
|
+
* appears in the command line, we never block — otherwise the guard would trap
|
|
1969
|
+
* the agent by blocking the exact command it needs to dig out. Matched as
|
|
1970
|
+
* whole words so `remove-item` etc. don't accidentally match `rm`.
|
|
1971
|
+
*/
|
|
1972
|
+
const RECLAIM_PATTERNS = [
|
|
1973
|
+
/\brm\b/,
|
|
1974
|
+
/\brmdir\b/,
|
|
1975
|
+
/\bdf\b/,
|
|
1976
|
+
/\bdu\b/,
|
|
1977
|
+
/\bncdu\b/,
|
|
1978
|
+
/\bfind\b[^|]*\s-delete\b/,
|
|
1979
|
+
/\btruncate\b/,
|
|
1980
|
+
/\bgit\s+(gc|prune|clean|worktree\s+remove|worktree\s+prune)\b/,
|
|
1981
|
+
/\b(yarn|npm|pnpm|bun)\s+.*\b(cache\s+clean|cache\s+clear|store\s+prune)\b/,
|
|
1982
|
+
/\bcache\s+(clean|clear|prune)\b/,
|
|
1983
|
+
/\b(docker|podman)\s+.*\bprune\b/,
|
|
1984
|
+
/\bapt(-get)?\s+clean\b/,
|
|
1985
|
+
/\bjournalctl\b[^|]*--vacuum/
|
|
1986
|
+
];
|
|
1987
|
+
/**
|
|
1988
|
+
* File extensions that mean a download is actually LARGE — archives, disk
|
|
1989
|
+
* images, compiled/binary artifacts, model weights, media. A curl/wget is only
|
|
1990
|
+
* gated when it writes one of these; an API/page fetch to a `.json`/`.html`/
|
|
1991
|
+
* `.txt` file is tiny and must not be blocked. Derived from 4,144 real
|
|
1992
|
+
* commands: ~64% of `curl -o` uses were tiny fetches, only ~4% large.
|
|
1993
|
+
*/
|
|
1994
|
+
const BIG_DOWNLOAD_EXT = "(?:tar\\.gz|tgz|tar|zip|iso|gz|bz2|xz|zst|deb|rpm|pkg|dmg|whl|jar|7z|img|mp4|mov|avi|mkv|onnx|gguf|safetensors|bin|node)";
|
|
1995
|
+
/**
|
|
1996
|
+
* Commands that consume a meaningful amount of disk. Kept deliberately tight
|
|
1997
|
+
* and high-precision: validated against 4,144 real commands from the last 7
|
|
1998
|
+
* days, the earlier "writes a file" heuristic flagged 82% of everything (a
|
|
1999
|
+
* `curl -o /tmp/x.json` API call is not a disk event). This set flags ~33%,
|
|
2000
|
+
* almost all genuinely large — real installs, clones, big-archive downloads,
|
|
2001
|
+
* extractions. What was DROPPED and why:
|
|
2002
|
+
* - `git fetch` / `git pull` — incremental on an existing clone, usually tiny.
|
|
2003
|
+
* - `git checkout` — overwhelmingly `git checkout <ref> -- <file>` or a
|
|
2004
|
+
* branch switch, ~zero net growth; the rare full materialization isn't
|
|
2005
|
+
* worth the false-positive rate.
|
|
2006
|
+
* - bare `curl -o` / `wget -o` — see BIG_DOWNLOAD_EXT above.
|
|
2007
|
+
* - loose `… build` — matched `--mode=skip-build`, `oxfmt … build`, prose.
|
|
2008
|
+
* The remaining big-disk op in escher is `git clone` and `git worktree add`
|
|
2009
|
+
* (which is really a checkout), both kept.
|
|
2010
|
+
*/
|
|
2011
|
+
const SPACE_HUNGRY_PATTERNS = [
|
|
2012
|
+
/\bgit\s+clone\b/,
|
|
2013
|
+
/\bgit\s+worktree\s+add\b/,
|
|
2014
|
+
/\b(yarn|npm|pnpm|bun)\s+(install|add|ci)\b/,
|
|
2015
|
+
/\byarn\s*$/,
|
|
2016
|
+
/\byarn\s+--(?!version|help)\S/,
|
|
2017
|
+
/\bpip3?\s+install\b/,
|
|
2018
|
+
/\bapt(-get)?\s+install\b/,
|
|
2019
|
+
/\bnpm\s+pack\b/,
|
|
2020
|
+
/\bdocker\s+(build|pull)\b/,
|
|
2021
|
+
new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b`, "i"),
|
|
2022
|
+
new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b`, "i"),
|
|
2023
|
+
/\btar\s+[^\n|]*x[^\n|]*f/,
|
|
2024
|
+
/\bunzip\b/,
|
|
2025
|
+
/\bdd\b[^\n|]*\bof=/
|
|
2026
|
+
];
|
|
2027
|
+
/**
|
|
2028
|
+
* True when the command reclaims or inspects space — these are always allowed,
|
|
2029
|
+
* even on a 100%-full box, so the agent can dig itself out.
|
|
2030
|
+
*/
|
|
2031
|
+
function isReclaimCommand(command) {
|
|
2032
|
+
return RECLAIM_PATTERNS.some((re) => re.test(command));
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* True when the command is likely to consume a meaningful amount of disk.
|
|
2036
|
+
* A reclaim/inspect command is never space-hungry — the reclaim check wins so a
|
|
2037
|
+
* `git worktree remove` or a `yarn cache clean` is never mistaken for growth.
|
|
2038
|
+
*/
|
|
2039
|
+
function isSpaceHungryCommand(command) {
|
|
2040
|
+
if (isReclaimCommand(command)) return false;
|
|
2041
|
+
return SPACE_HUNGRY_PATTERNS.some((re) => re.test(command));
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* The decision, factored out and pure so it's exhaustively testable without a
|
|
2045
|
+
* real filesystem. Block only when we have a disk reading, it's at/above the
|
|
2046
|
+
* critical threshold, the command is space-hungry (and not a reclaim), and the
|
|
2047
|
+
* agent hasn't explicitly opted out with the bypass marker.
|
|
2048
|
+
*/
|
|
2049
|
+
function shouldBlockForDisk(command, diskPct) {
|
|
2050
|
+
if (diskPct === null) return false;
|
|
2051
|
+
if (diskPct < 95) return false;
|
|
2052
|
+
if (hasBypassMarker(command)) return false;
|
|
2053
|
+
return isSpaceHungryCommand(command);
|
|
2054
|
+
}
|
|
2055
|
+
/** The agent-facing explanation returned as the blocked tool result. */
|
|
2056
|
+
function diskBlockReason(command, diskPct) {
|
|
2057
|
+
return `Blocked: the sandbox disk is ${diskPct}% full and this command (\`${command.trim().slice(0, 120)}\`) writes a large amount, so it would fail partway with ENOSPC and leave a corrupt result. Reclaim space FIRST, then retry. Free ONLY what THIS conversation created — scratch/build output you wrote this run, downloads you're done with, and worktrees/branches whose work you've already committed and pushed (\`git worktree remove\`, \`yarn cache clean\`, delete your own scratch). Do NOT blindly wipe /tmp or delete a clone/worktree you don't recognize — other conversations share this box. Check headroom with \`df -h /\` and \`du -sh ~/workspace/* 2>/dev/null\`. If you genuinely can't free enough, stop and tell the user you're blocked on disk rather than retrying the write. If you're certain this command is safe anyway (writes elsewhere, tiny bounded size, delete-then-write), force it through by appending \` ${BYPASS_HINT}\` to the command.`;
|
|
2058
|
+
}
|
|
2059
|
+
const diskGuardExtension = (pi) => {
|
|
2060
|
+
pi.on("tool_call", async (event) => {
|
|
2061
|
+
if (event.toolName !== "bash") return;
|
|
2062
|
+
if (guardDisabledByEnv()) return;
|
|
2063
|
+
const command = event.input.command;
|
|
2064
|
+
if (typeof command !== "string" || command.length === 0) return;
|
|
2065
|
+
if (hasBypassMarker(command)) return;
|
|
2066
|
+
if (!isSpaceHungryCommand(command)) return;
|
|
2067
|
+
const diskPct = await readDiskUsePct();
|
|
2068
|
+
if (!shouldBlockForDisk(command, diskPct)) return;
|
|
2069
|
+
log$11.warn({
|
|
2070
|
+
diskPct,
|
|
2071
|
+
command: command.slice(0, 200)
|
|
2072
|
+
}, "blocked space-hungry bash command on near-full disk");
|
|
2073
|
+
return {
|
|
2074
|
+
block: true,
|
|
2075
|
+
reason: diskBlockReason(command, diskPct)
|
|
2076
|
+
};
|
|
2077
|
+
});
|
|
2078
|
+
};
|
|
2079
|
+
//#endregion
|
|
1642
2080
|
//#region src/channel-context-ref.ts
|
|
1643
2081
|
/**
|
|
1644
|
-
* The worker injects
|
|
1645
|
-
* sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
2082
|
+
* The worker injects a small reference — `{ channel, messageId, runId }` —
|
|
2083
|
+
* into the sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
1646
2084
|
*
|
|
1647
2085
|
* The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
|
|
1648
2086
|
* `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
|
|
1649
2087
|
* `@createinc/*` dependencies — importing that package would pull the whole
|
|
1650
|
-
* platform channel stack (Slack/email/Linq SDKs, messaging)
|
|
1651
|
-
*
|
|
2088
|
+
* platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
|
|
2089
|
+
* field. So we validate the field this consumer needs locally instead.
|
|
1652
2090
|
*/
|
|
1653
2091
|
const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
|
|
1654
2092
|
/**
|
|
@@ -1680,6 +2118,23 @@ function apiBaseUrl() {
|
|
|
1680
2118
|
}
|
|
1681
2119
|
//#endregion
|
|
1682
2120
|
//#region src/extensions/platform.ts
|
|
2121
|
+
/**
|
|
2122
|
+
* Platform extension — bridges the agent harness to the Skydive platform daemon.
|
|
2123
|
+
*
|
|
2124
|
+
* Responsibilities:
|
|
2125
|
+
* - Heartbeat: periodic POST to the API so the sandbox manager knows the
|
|
2126
|
+
* agent is alive. Throttled to once per minute, triggered by tool events.
|
|
2127
|
+
* - Session tracking: registers the session with the daemon on start,
|
|
2128
|
+
* streams tool_call / tool_result events so the daemon can track which
|
|
2129
|
+
* session is actively executing, and signals session end on agent_end.
|
|
2130
|
+
* - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
|
|
2131
|
+
* messageId) to the daemon so file writes can be attributed to the
|
|
2132
|
+
* correct conversation.
|
|
2133
|
+
*
|
|
2134
|
+
* All daemon POSTs are fire-and-forget — failures are logged but never
|
|
2135
|
+
* block the agent. The daemon may not be running (e.g. local dev without
|
|
2136
|
+
* a sandbox), and that's fine.
|
|
2137
|
+
*/
|
|
1683
2138
|
const HEARTBEAT_THROTTLE_MS = 6e4;
|
|
1684
2139
|
const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1685
2140
|
const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
|
|
@@ -1691,9 +2146,56 @@ function sandboxClient() {
|
|
|
1691
2146
|
return hc(`${apiUrl}/api/v1/sandbox`);
|
|
1692
2147
|
}
|
|
1693
2148
|
/**
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
2149
|
+
* Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
|
|
2150
|
+
* feature-flags half of the ANY-5184 pool 403 wave.)
|
|
2151
|
+
*
|
|
2152
|
+
* `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
|
|
2153
|
+
* from a pool box can only 403 — a guaranteed-failing GET every 60s for the
|
|
2154
|
+
* life of the pool phase. The discriminator is the sandbox token's `type`
|
|
2155
|
+
* claim, read UNVERIFIED (this box never holds the signing secret): not an
|
|
2156
|
+
* authorization decision, only "should I bother calling?", and the api still
|
|
2157
|
+
* authorizes every request.
|
|
2158
|
+
*
|
|
2159
|
+
* Read per call from the daemon's persisted env file, NOT process.env:
|
|
2160
|
+
* claiming a pool box rebinds the token in place (the daemon rewrites this
|
|
2161
|
+
* file) while the harness's process.env keeps the boot snapshot, so a
|
|
2162
|
+
* process-env gate would leave a claimed box permanently skipping — trading a
|
|
2163
|
+
* wasted request for silently frozen flags, which is strictly worse. "Cannot
|
|
2164
|
+
* tell" (no file, no token, unparseable payload) reports false so the poll
|
|
2165
|
+
* proceeds.
|
|
2166
|
+
*/
|
|
2167
|
+
const daemonEnvIdentitySchema = z.object({
|
|
2168
|
+
ANYONE_SANDBOX_TOKEN: z.string().optional(),
|
|
2169
|
+
SKYDIVE_SANDBOX_TOKEN: z.string().optional()
|
|
2170
|
+
}).passthrough();
|
|
2171
|
+
const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
|
|
2172
|
+
async function isPoolIdentity() {
|
|
2173
|
+
try {
|
|
2174
|
+
const override = process.env.ANYONE_DAEMON_ENV_CACHE;
|
|
2175
|
+
const candidates = override ? [override] : ["/run/anyone-system/daemon-env.json", "/tmp/.anyone/daemon-env.json"];
|
|
2176
|
+
let raw = null;
|
|
2177
|
+
for (const file of candidates) {
|
|
2178
|
+
raw = await readFile(file, "utf8").catch(() => null);
|
|
2179
|
+
if (raw !== null) break;
|
|
2180
|
+
}
|
|
2181
|
+
if (raw === null) return false;
|
|
2182
|
+
const env = daemonEnvIdentitySchema.safeParse(JSON.parse(raw));
|
|
2183
|
+
if (!env.success) return false;
|
|
2184
|
+
const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
|
|
2185
|
+
if (typeof token !== "string" || token === "") return false;
|
|
2186
|
+
const payload = token.split(".")[1];
|
|
2187
|
+
if (!payload) return false;
|
|
2188
|
+
const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
2189
|
+
return claims.success && claims.data.type === "onboarding-pool";
|
|
2190
|
+
} catch (_err) {
|
|
2191
|
+
return false;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
|
|
2196
|
+
* — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
|
|
2197
|
+
* null when indeterminate (no api url, the request failed, or the box is an
|
|
2198
|
+
* unclaimed pool sandbox whose token the route would 403) so the shared
|
|
1697
2199
|
* poller keeps the last-known values rather than flipping on a transient error.
|
|
1698
2200
|
* This is the single fetch behind `feature-flags-poll.ts`; extensions read the
|
|
1699
2201
|
* polled values there instead of issuing their own GET.
|
|
@@ -1701,6 +2203,7 @@ function sandboxClient() {
|
|
|
1701
2203
|
async function fetchHarnessFlags() {
|
|
1702
2204
|
const client = sandboxClient();
|
|
1703
2205
|
if (!client) return null;
|
|
2206
|
+
if (await isPoolIdentity()) return null;
|
|
1704
2207
|
try {
|
|
1705
2208
|
const res = await client["feature-flags"].$get();
|
|
1706
2209
|
if (!res.ok) {
|
|
@@ -1710,11 +2213,7 @@ async function fetchHarnessFlags() {
|
|
|
1710
2213
|
}, "feature-flags fetch failed");
|
|
1711
2214
|
return null;
|
|
1712
2215
|
}
|
|
1713
|
-
|
|
1714
|
-
return {
|
|
1715
|
-
contextManagement: body.contextManagement ?? null,
|
|
1716
|
-
subagent: body.subagent ?? null
|
|
1717
|
-
};
|
|
2216
|
+
return { contextManagement: (await res.json()).contextManagement ?? null };
|
|
1718
2217
|
} catch (err) {
|
|
1719
2218
|
log$10.debug({
|
|
1720
2219
|
err,
|
|
@@ -1765,6 +2264,19 @@ async function postBackgroundTaskDone({ messageId, content }) {
|
|
|
1765
2264
|
} });
|
|
1766
2265
|
if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
|
|
1767
2266
|
}
|
|
2267
|
+
function postBackgroundTasksSnapshot({ messageId, tasks }) {
|
|
2268
|
+
const client = sandboxClient();
|
|
2269
|
+
if (!client || !messageId) return;
|
|
2270
|
+
client["bg-tasks"].$post({ json: {
|
|
2271
|
+
messageId,
|
|
2272
|
+
tasks
|
|
2273
|
+
} }).catch((err) => {
|
|
2274
|
+
log$10.debug({
|
|
2275
|
+
err,
|
|
2276
|
+
event: "bg_tasks_snapshot_failed"
|
|
2277
|
+
}, "bg-tasks snapshot publish failed");
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
1768
2280
|
async function postSubagentSpawn({ messageId, tasks }) {
|
|
1769
2281
|
const client = sandboxClient();
|
|
1770
2282
|
if (!client) throw new Error("no api url for subagent-spawn");
|
|
@@ -1772,8 +2284,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
|
|
|
1772
2284
|
messageId,
|
|
1773
2285
|
tasks
|
|
1774
2286
|
} });
|
|
1775
|
-
if (!res.ok)
|
|
1776
|
-
|
|
2287
|
+
if (!res.ok) {
|
|
2288
|
+
let detail = "";
|
|
2289
|
+
try {
|
|
2290
|
+
const errBody = await res.json();
|
|
2291
|
+
if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
|
|
2292
|
+
} catch {}
|
|
2293
|
+
throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
|
|
2294
|
+
}
|
|
2295
|
+
const body = await res.json();
|
|
2296
|
+
return {
|
|
2297
|
+
taskIds: body.taskIds,
|
|
2298
|
+
tasks: body.tasks ?? []
|
|
2299
|
+
};
|
|
1777
2300
|
}
|
|
1778
2301
|
function createHeartbeatThrottle({ messageId }) {
|
|
1779
2302
|
let lastAt = 0;
|
|
@@ -1922,20 +2445,16 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1922
2445
|
* Shared harness feature-flag poll.
|
|
1923
2446
|
*
|
|
1924
2447
|
* The api exposes one `/feature-flags` GET that returns every harness flag in a
|
|
1925
|
-
* single response (`{ contextManagement,
|
|
2448
|
+
* single response (`{ contextManagement, commandFlags }` — see
|
|
1926
2449
|
* apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
|
|
1927
2450
|
* extension issuing its own GET — and, worse, a *blocking* GET on the
|
|
1928
2451
|
* pre-first-token `session_start` path — a single background poller fetches
|
|
1929
2452
|
* that response once per interval and fans the values out to every subscriber.
|
|
1930
2453
|
*
|
|
1931
|
-
* Why one poller:
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
* every session, flag on or off. Reading the last-polled value instead keeps
|
|
1936
|
-
* the hot path allocation-only. A cold cache reads as `null` (fail-open to
|
|
1937
|
-
* unregistered); a newly-flipped flag takes effect on the next poll, matching
|
|
1938
|
-
* how context-management already treats its flag.
|
|
2454
|
+
* Why one poller: context-management consumes the `contextManagement` flag
|
|
2455
|
+
* without a blocking GET on the pre-first-token `session_start` path. Reading
|
|
2456
|
+
* the last-polled value keeps the hot path allocation-only; a cold cache reads
|
|
2457
|
+
* as `null` and a newly-flipped flag takes effect on the next poll.
|
|
1939
2458
|
*
|
|
1940
2459
|
* The poll is fire-and-forget and self-unref'd — it never keeps the process
|
|
1941
2460
|
* alive and an indeterminate result (no api url / transient failure) leaves the
|
|
@@ -1944,15 +2463,11 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1944
2463
|
const log$9 = logger.child({ module: "feature-flags-poll" });
|
|
1945
2464
|
const FLAG_POLL_INTERVAL_MS = 6e4;
|
|
1946
2465
|
let contextManagement = null;
|
|
1947
|
-
|
|
1948
|
-
const subscribers = {
|
|
1949
|
-
contextManagement: /* @__PURE__ */ new Set(),
|
|
1950
|
-
subagent: /* @__PURE__ */ new Set()
|
|
1951
|
-
};
|
|
2466
|
+
const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
|
|
1952
2467
|
let pollerStarted = false;
|
|
1953
2468
|
let firstPollSettled = false;
|
|
1954
2469
|
let resolveFirstPoll = null;
|
|
1955
|
-
|
|
2470
|
+
new Promise((resolve) => {
|
|
1956
2471
|
resolveFirstPoll = resolve;
|
|
1957
2472
|
});
|
|
1958
2473
|
function markFirstPollSettled() {
|
|
@@ -1961,20 +2476,8 @@ function markFirstPollSettled() {
|
|
|
1961
2476
|
resolveFirstPoll?.();
|
|
1962
2477
|
}
|
|
1963
2478
|
/** Last-polled value of a flag, or `null` if not yet resolved. */
|
|
1964
|
-
function getPolledFlag(
|
|
1965
|
-
return
|
|
1966
|
-
}
|
|
1967
|
-
/**
|
|
1968
|
-
* Await the first poll already kicked by `startFeatureFlagPoller` (never a new
|
|
1969
|
-
* GET). Resolves when that poll settles, immediately if it already has, or
|
|
1970
|
-
* immediately when there's no flag source to poll. Callers on the hot path
|
|
1971
|
-
* should race this against their own short timeout so a slow/failed flag
|
|
1972
|
-
* service cannot delay first-token; a timeout just means the caller reads the
|
|
1973
|
-
* still-cold cache and falls back to its default, exactly as before.
|
|
1974
|
-
*/
|
|
1975
|
-
function awaitFirstFlagPoll() {
|
|
1976
|
-
if (firstPollSettled || !hasFlagSource()) return Promise.resolve();
|
|
1977
|
-
return firstPollPromise;
|
|
2479
|
+
function getPolledFlag(_name) {
|
|
2480
|
+
return contextManagement;
|
|
1978
2481
|
}
|
|
1979
2482
|
/**
|
|
1980
2483
|
* Subscribe to changes of a flag. The callback fires only on a *transition*
|
|
@@ -1987,9 +2490,8 @@ function onFlagChange(name, cb) {
|
|
|
1987
2490
|
}
|
|
1988
2491
|
function apply(name, next) {
|
|
1989
2492
|
if (next === null) return;
|
|
1990
|
-
const prev =
|
|
1991
|
-
|
|
1992
|
-
else subagent = next;
|
|
2493
|
+
const prev = contextManagement;
|
|
2494
|
+
contextManagement = next;
|
|
1993
2495
|
if (next !== prev) for (const cb of subscribers[name]) try {
|
|
1994
2496
|
cb(next);
|
|
1995
2497
|
} catch (err) {
|
|
@@ -2004,7 +2506,6 @@ async function pollOnce() {
|
|
|
2004
2506
|
const flags = await fetchHarnessFlags();
|
|
2005
2507
|
if (!flags) return;
|
|
2006
2508
|
apply("contextManagement", flags.contextManagement ?? null);
|
|
2007
|
-
apply("subagent", flags.subagent ?? null);
|
|
2008
2509
|
} catch (err) {
|
|
2009
2510
|
log$9.debug({ err }, "feature-flag poll threw");
|
|
2010
2511
|
}
|
|
@@ -2236,37 +2737,35 @@ const currentTimeExtension = (pi) => {
|
|
|
2236
2737
|
//#endregion
|
|
2237
2738
|
//#region src/memory.ts
|
|
2238
2739
|
/**
|
|
2239
|
-
* In-harness memory
|
|
2240
|
-
*
|
|
2241
|
-
* The agent has an agent-level file-based memory at `<cwd>/.memory/`,
|
|
2242
|
-
* organized by directory:
|
|
2740
|
+
* In-harness memory readers.
|
|
2243
2741
|
*
|
|
2244
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
* .memory/feedback/<topic>.md
|
|
2247
|
-
* .memory/reference/<topic>.md
|
|
2742
|
+
* The agent has an agent-level file-based memory at `<cwd>/.memory/`, split
|
|
2743
|
+
* into two halves that are surfaced differently:
|
|
2248
2744
|
*
|
|
2249
|
-
*
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2745
|
+
* 1. Shared knowledge (projects, lessons, external systems) — indexed by a
|
|
2746
|
+
* single hand-maintained `<cwd>/.memory/MEMORY.md` that the *agent* writes
|
|
2747
|
+
* and curates, Claude-Code style: one line per fact pointing at the file
|
|
2748
|
+
* that holds it. `readMemoryIndexFile` just reads that file; the agent owns
|
|
2749
|
+
* its contents. This is the whole index for the shared half — there is no
|
|
2750
|
+
* derived walk and no per-directory `MEMORY.md`.
|
|
2252
2751
|
*
|
|
2253
|
-
*
|
|
2254
|
-
*
|
|
2255
|
-
*
|
|
2256
|
-
*
|
|
2257
|
-
*
|
|
2258
|
-
*
|
|
2259
|
-
*
|
|
2752
|
+
* 2. Per-person memory — `.memory/users/<id>-<name>/<topic>.md`. This half is
|
|
2753
|
+
* *derived*, not hand-maintained, because it has to be filtered to the one
|
|
2754
|
+
* person on the current turn (a single hand-written index couldn't be
|
|
2755
|
+
* scoped per-user without leaking one person's notes into another's
|
|
2756
|
+
* conversation). `buildMemoryIndex` walks a single user's directory, reads
|
|
2757
|
+
* only the frontmatter of each `.md` (open fd → read first ~4KB → close, in
|
|
2758
|
+
* parallel), and renders an index. Each `.md`'s frontmatter carries `name`
|
|
2759
|
+
* and `description`; bodies are never read — the agent loads a specific
|
|
2760
|
+
* memory's body on demand via the `read` tool.
|
|
2260
2761
|
*
|
|
2261
|
-
* Mtime cache keyed by cwd — within the
|
|
2262
|
-
* is fixed, so this is effectively a single-entry
|
|
2263
|
-
* when any `.md`
|
|
2264
|
-
* memory didn't change reuse the cached
|
|
2762
|
+
* Mtime cache (for the derived per-user half) keyed by cwd — within the
|
|
2763
|
+
* lifetime of a sandbox the cwd is fixed, so this is effectively a single-entry
|
|
2764
|
+
* cache. It invalidates when any `.md` under `users/` is added/modified/
|
|
2765
|
+
* deleted; turns where memory didn't change reuse the cached entries.
|
|
2265
2766
|
*
|
|
2266
|
-
* Frontmatter is parsed as YAML (`yaml` package) and validated with a
|
|
2267
|
-
*
|
|
2268
|
-
* index. The same schema can be reused at write time if we want to
|
|
2269
|
-
* validate before commit.
|
|
2767
|
+
* Frontmatter is parsed as YAML (`yaml` package) and validated with a zod
|
|
2768
|
+
* schema — files that don't match the shape are dropped from the index.
|
|
2270
2769
|
*/
|
|
2271
2770
|
const FRONTMATTER_READ_BYTES = 4096;
|
|
2272
2771
|
const FrontmatterSchema = z.object({
|
|
@@ -2274,33 +2773,118 @@ const FrontmatterSchema = z.object({
|
|
|
2274
2773
|
description: z.string().min(1)
|
|
2275
2774
|
}).passthrough();
|
|
2276
2775
|
const MEMORY_DIRNAME = ".memory";
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2776
|
+
const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
2777
|
+
const USERS_DIRNAME = "users";
|
|
2778
|
+
/**
|
|
2779
|
+
* The shared-knowledge type dirs from the old frontmatter-indexed layout, used
|
|
2780
|
+
* only to seed a `MEMORY.md` for agents created before it existed (see
|
|
2781
|
+
* `seedMemoryIndexFile`). `users/` is deliberately excluded — per-person memory
|
|
2782
|
+
* stays derived and never lands in the shared, un-scoped `MEMORY.md`.
|
|
2783
|
+
*/
|
|
2784
|
+
const LEGACY_SHARED_TYPES = [
|
|
2785
|
+
{
|
|
2786
|
+
dir: "projects",
|
|
2787
|
+
label: "Projects"
|
|
2788
|
+
},
|
|
2789
|
+
{
|
|
2790
|
+
dir: "feedback",
|
|
2791
|
+
label: "Feedback"
|
|
2792
|
+
},
|
|
2793
|
+
{
|
|
2794
|
+
dir: "reference",
|
|
2795
|
+
label: "Reference"
|
|
2796
|
+
}
|
|
2282
2797
|
];
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2798
|
+
/**
|
|
2799
|
+
* Soft budget for an injected index block. The index is read and injected into
|
|
2800
|
+
* the system prompt on every turn, so every entry costs context for the rest of
|
|
2801
|
+
* the conversation. Past this size we nudge the agent to consolidate and prune
|
|
2802
|
+
* rather than keep appending. Not a hard cap — nothing is truncated.
|
|
2803
|
+
*/
|
|
2804
|
+
const MEMORY_INDEX_SOFT_BUDGET_CHARS = 2e4;
|
|
2805
|
+
/**
|
|
2806
|
+
* Hard cap on the injected index — double the soft budget. The soft budget only
|
|
2807
|
+
* warns; this actually bounds what we inject so a runaway index can't consume
|
|
2808
|
+
* unbounded context on every turn. Past this, the index is truncated (on a line
|
|
2809
|
+
* boundary) before injection. It's a backstop, not a normal operating point.
|
|
2810
|
+
*/
|
|
2811
|
+
const MEMORY_INDEX_HARD_BUDGET_CHARS = MEMORY_INDEX_SOFT_BUDGET_CHARS * 2;
|
|
2812
|
+
/**
|
|
2813
|
+
* Cheap size summary of a rendered index block, used to surface how much of the
|
|
2814
|
+
* every-turn context budget the index is spending so the agent keeps it lean.
|
|
2815
|
+
* Counts pointer/file lines — both the derived `` - `path` — desc`` form and
|
|
2816
|
+
* the hand-maintained `- [Title](path) — hook` form — not the group headers.
|
|
2817
|
+
*/
|
|
2818
|
+
function summarizeIndex(index) {
|
|
2819
|
+
const entryCount = index.split("\n").filter((line) => /^\s*- (?:`|\[)/.test(line)).length;
|
|
2820
|
+
const charCount = index.length;
|
|
2821
|
+
return {
|
|
2822
|
+
entryCount,
|
|
2823
|
+
charCount,
|
|
2824
|
+
overBudget: charCount > MEMORY_INDEX_SOFT_BUDGET_CHARS
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* One-line size note for an index block header, e.g. `12 entries, 3187 chars`.
|
|
2829
|
+
*/
|
|
2830
|
+
function indexSizeNote(index) {
|
|
2831
|
+
const { entryCount, charCount } = summarizeIndex(index);
|
|
2832
|
+
return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}, ${charCount} chars`;
|
|
2833
|
+
}
|
|
2834
|
+
/**
|
|
2835
|
+
* An explicit warning to surface to the agent when an index has grown past its
|
|
2836
|
+
* budget, or `null` when it's within budget. Extensions render this prominently
|
|
2837
|
+
* above the index so the agent prunes before it keeps appending.
|
|
2838
|
+
*/
|
|
2839
|
+
function indexBudgetWarning(index) {
|
|
2840
|
+
const { charCount, overBudget } = summarizeIndex(index);
|
|
2841
|
+
if (!overBudget) return null;
|
|
2842
|
+
return `⚠️ This memory index is ${charCount} chars, over its ${MEMORY_INDEX_SOFT_BUDGET_CHARS}-char budget. It's costing you context on every turn — consolidate duplicate entries and delete stale ones to bring it back under budget before adding anything new.`;
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Enforce the hard cap on an index before injection. Under the cap the index is
|
|
2846
|
+
* returned unchanged; over it, the index is truncated on a line boundary and a
|
|
2847
|
+
* notice is appended naming the true size so the agent knows entries are hidden
|
|
2848
|
+
* and must be pruned. This is the actual bound on injected context — callers
|
|
2849
|
+
* still report the true size via {@link indexSizeNote} so nothing is masked.
|
|
2850
|
+
*/
|
|
2851
|
+
function enforceMemoryIndexHardBudget(index) {
|
|
2852
|
+
if (index.length <= 4e4) return index;
|
|
2853
|
+
const clipped = index.slice(0, MEMORY_INDEX_HARD_BUDGET_CHARS);
|
|
2854
|
+
const lastNewline = clipped.lastIndexOf("\n");
|
|
2855
|
+
return `${lastNewline > 0 ? clipped.slice(0, lastNewline) : clipped}\n\n⚠️ Memory index truncated at ${MEMORY_INDEX_HARD_BUDGET_CHARS} chars (it is ${index.length}). Entries past this point are NOT shown. Prune the index now — delete stale entries and consolidate duplicates.`;
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Read the agent's hand-maintained shared index at `.memory/MEMORY.md`.
|
|
2859
|
+
* Returns the trimmed contents, or `null` when the file is absent or empty —
|
|
2860
|
+
* the agent owns this file, so we surface exactly what it wrote.
|
|
2861
|
+
*/
|
|
2862
|
+
async function readMemoryIndexFile({ cwd }) {
|
|
2863
|
+
const path = join(cwd, MEMORY_DIRNAME, MEMORY_INDEX_FILENAME);
|
|
2864
|
+
try {
|
|
2865
|
+
const trimmed = (await readFile(path, "utf-8")).trim();
|
|
2866
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
2867
|
+
} catch {
|
|
2868
|
+
return null;
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2290
2871
|
const cache = /* @__PURE__ */ new Map();
|
|
2291
2872
|
/**
|
|
2873
|
+
* Build the derived per-person index for a single user.
|
|
2874
|
+
*
|
|
2292
2875
|
* Returns:
|
|
2293
|
-
* - `null` if `.memory/` doesn't exist
|
|
2294
|
-
* - `""` if
|
|
2876
|
+
* - `null` if `.memory/users/` doesn't exist
|
|
2877
|
+
* - `""` if it exists but this user has no memory
|
|
2295
2878
|
* - rendered markdown body (no surrounding header — caller wraps)
|
|
2296
2879
|
*
|
|
2297
|
-
*
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2880
|
+
* Scoping by id keeps one person's memory from bleeding into another's
|
|
2881
|
+
* conversation. The mtime-keyed cache stores the raw walked entries (the cost
|
|
2882
|
+
* is the FS walk); filtering by user is cheap and runs per call, so two turns
|
|
2883
|
+
* with different users on the same cwd render correctly from one cached walk.
|
|
2300
2884
|
*/
|
|
2301
|
-
async function buildMemoryIndex({ cwd,
|
|
2302
|
-
const
|
|
2303
|
-
const maxMtimeMs = await maxMtimeAcrossDir(
|
|
2885
|
+
async function buildMemoryIndex({ cwd, userId }) {
|
|
2886
|
+
const usersDirAbs = join(cwd, MEMORY_DIRNAME, USERS_DIRNAME);
|
|
2887
|
+
const maxMtimeMs = await maxMtimeAcrossDir(usersDirAbs);
|
|
2304
2888
|
if (maxMtimeMs === null) {
|
|
2305
2889
|
cache.delete(cwd);
|
|
2306
2890
|
return null;
|
|
@@ -2308,13 +2892,13 @@ async function buildMemoryIndex({ cwd, scope }) {
|
|
|
2308
2892
|
let cached = cache.get(cwd);
|
|
2309
2893
|
if (!cached || cached.builtAtMs < maxMtimeMs) {
|
|
2310
2894
|
cached = {
|
|
2311
|
-
entries: await
|
|
2895
|
+
entries: await collectUserEntries(usersDirAbs, cwd),
|
|
2312
2896
|
builtAtMs: Date.now()
|
|
2313
2897
|
};
|
|
2314
2898
|
cache.set(cwd, cached);
|
|
2315
2899
|
}
|
|
2316
|
-
const visible = cached.entries.filter((entry) =>
|
|
2317
|
-
return visible.length === 0 ? "" :
|
|
2900
|
+
const visible = cached.entries.filter((entry) => entry.subject.startsWith(userId));
|
|
2901
|
+
return visible.length === 0 ? "" : renderUserIndex(visible);
|
|
2318
2902
|
}
|
|
2319
2903
|
async function maxMtimeAcrossDir(dir) {
|
|
2320
2904
|
let dirStat;
|
|
@@ -2362,45 +2946,22 @@ async function listSubdirs(dir) {
|
|
|
2362
2946
|
}
|
|
2363
2947
|
return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
|
|
2364
2948
|
}
|
|
2365
|
-
async function
|
|
2366
|
-
const
|
|
2367
|
-
await Promise.all(
|
|
2368
|
-
const
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
await
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
subject,
|
|
2382
|
-
relPath: relative(cwd, file)
|
|
2383
|
-
};
|
|
2384
|
-
}));
|
|
2385
|
-
for (const e of parsed) if (e) collected.push(e);
|
|
2386
|
-
}));
|
|
2387
|
-
} else {
|
|
2388
|
-
const files = await listMdFilesShallow(typeDirAbs);
|
|
2389
|
-
const parsed = await Promise.all(files.map(async (file) => {
|
|
2390
|
-
const fm = await readFrontmatterOnly(file);
|
|
2391
|
-
if (!fm?.name || !fm?.description) return null;
|
|
2392
|
-
return {
|
|
2393
|
-
name: fm.name,
|
|
2394
|
-
description: fm.description,
|
|
2395
|
-
type,
|
|
2396
|
-
subject: null,
|
|
2397
|
-
relPath: relative(cwd, file)
|
|
2398
|
-
};
|
|
2399
|
-
}));
|
|
2400
|
-
for (const e of parsed) if (e) collected.push(e);
|
|
2401
|
-
}
|
|
2402
|
-
}));
|
|
2403
|
-
return collected;
|
|
2949
|
+
async function collectUserEntries(usersDirAbs, cwd) {
|
|
2950
|
+
const subjectDirs = await listSubdirs(usersDirAbs);
|
|
2951
|
+
return (await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
|
|
2952
|
+
const subject = basename(subjectDirAbs);
|
|
2953
|
+
const files = await listMdFilesShallow(subjectDirAbs);
|
|
2954
|
+
return (await Promise.all(files.map(async (file) => {
|
|
2955
|
+
const fm = await readFrontmatterOnly(file);
|
|
2956
|
+
if (!fm?.name || !fm?.description) return null;
|
|
2957
|
+
return {
|
|
2958
|
+
name: fm.name,
|
|
2959
|
+
description: fm.description,
|
|
2960
|
+
subject,
|
|
2961
|
+
relPath: relative(cwd, file)
|
|
2962
|
+
};
|
|
2963
|
+
}))).filter((e) => e !== null);
|
|
2964
|
+
}))).flat();
|
|
2404
2965
|
}
|
|
2405
2966
|
async function readFrontmatterOnly(filePath) {
|
|
2406
2967
|
let fh;
|
|
@@ -2431,68 +2992,180 @@ function parseFrontmatter(text) {
|
|
|
2431
2992
|
const result = FrontmatterSchema.safeParse(parsed);
|
|
2432
2993
|
return result.success ? result.data : null;
|
|
2433
2994
|
}
|
|
2434
|
-
function
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2995
|
+
function renderUserIndex(entries) {
|
|
2996
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
2997
|
+
for (const e of entries) {
|
|
2998
|
+
const list = bySubject.get(e.subject) ?? [];
|
|
2999
|
+
list.push(e);
|
|
3000
|
+
bySubject.set(e.subject, list);
|
|
3001
|
+
}
|
|
3002
|
+
const lines = ["### Users"];
|
|
3003
|
+
for (const subject of [...bySubject.keys()].sort()) {
|
|
3004
|
+
lines.push(`- **${subject}**`);
|
|
3005
|
+
for (const e of bySubject.get(subject) ?? []) lines.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
3006
|
+
}
|
|
3007
|
+
return lines.join("\n");
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* One-time migration for agents created before `MEMORY.md` existed. If there is
|
|
3011
|
+
* no hand-maintained `.memory/MEMORY.md` yet but the agent has shared memory
|
|
3012
|
+
* files from the old frontmatter-indexed layout (`projects/`, `feedback/`,
|
|
3013
|
+
* `reference/`), derive a `MEMORY.md` from their frontmatter and write it once.
|
|
3014
|
+
* After that the agent owns the file — this never runs again for that agent and
|
|
3015
|
+
* never clobbers an existing index.
|
|
3016
|
+
*
|
|
3017
|
+
* Returns the seeded contents (also written to disk), or `null` when nothing
|
|
3018
|
+
* was seeded (index already present, or no legacy shared files). A write
|
|
3019
|
+
* failure propagates so the caller can log it; the read path then falls back to
|
|
3020
|
+
* whatever is on disk.
|
|
3021
|
+
*/
|
|
3022
|
+
async function seedMemoryIndexFile({ cwd }) {
|
|
3023
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3024
|
+
const indexPath = join(memoryDirAbs, MEMORY_INDEX_FILENAME);
|
|
3025
|
+
if (await stat(indexPath).catch(() => null)) return null;
|
|
3026
|
+
const perType = await Promise.all(LEGACY_SHARED_TYPES.map(async ({ dir, label }) => {
|
|
3027
|
+
const typeDirAbs = join(memoryDirAbs, dir);
|
|
3028
|
+
const files = [];
|
|
3029
|
+
await walkMdFiles(typeDirAbs, files);
|
|
3030
|
+
return {
|
|
3031
|
+
label,
|
|
3032
|
+
entries: (await Promise.all(files.map(async (file) => {
|
|
3033
|
+
const fm = await readFrontmatterOnly(file);
|
|
3034
|
+
if (!fm?.name || !fm?.description) return null;
|
|
3035
|
+
return {
|
|
3036
|
+
name: fm.name,
|
|
3037
|
+
description: fm.description,
|
|
3038
|
+
relPath: relative(cwd, file)
|
|
3039
|
+
};
|
|
3040
|
+
}))).filter((e) => !!e)
|
|
3041
|
+
};
|
|
3042
|
+
}));
|
|
3043
|
+
if (perType.every((group) => group.entries.length === 0)) return null;
|
|
3044
|
+
const content = renderSeededIndex(perType);
|
|
3045
|
+
await writeFile(indexPath, `${content}\n`, "utf-8");
|
|
3046
|
+
return content;
|
|
3047
|
+
}
|
|
3048
|
+
function renderSeededIndex(groups) {
|
|
2442
3049
|
const sections = [];
|
|
2443
|
-
for (const
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
const bySubject = /* @__PURE__ */ new Map();
|
|
2449
|
-
for (const e of items) {
|
|
2450
|
-
const subject = e.subject ?? "(unknown)";
|
|
2451
|
-
const list = bySubject.get(subject) ?? [];
|
|
2452
|
-
list.push(e);
|
|
2453
|
-
bySubject.set(subject, list);
|
|
2454
|
-
}
|
|
2455
|
-
const subjects = [...bySubject.keys()].sort();
|
|
2456
|
-
for (const subject of subjects) {
|
|
2457
|
-
sections.push(`- **${subject}**`);
|
|
2458
|
-
for (const e of bySubject.get(subject) ?? []) sections.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
2459
|
-
}
|
|
2460
|
-
} else for (const e of items) sections.push(`- \`${e.relPath}\` — ${e.description}`);
|
|
3050
|
+
for (const { label, entries } of groups) {
|
|
3051
|
+
if (entries.length === 0) continue;
|
|
3052
|
+
sections.push(`### ${label}`);
|
|
3053
|
+
const sorted = [...entries].sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
3054
|
+
for (const e of sorted) sections.push(`- [${e.name}](${e.relPath}) — ${e.description}`);
|
|
2461
3055
|
sections.push("");
|
|
2462
3056
|
}
|
|
2463
3057
|
return sections.join("\n").trimEnd();
|
|
2464
3058
|
}
|
|
3059
|
+
/**
|
|
3060
|
+
* The version at which the hand-maintained `MEMORY.md` layout was introduced.
|
|
3061
|
+
* Used to classify an unversioned `.memory/`: if it already has a `MEMORY.md`
|
|
3062
|
+
* it's on this layout (not the pre-MEMORY.md v1 frontmatter layout), so it
|
|
3063
|
+
* shouldn't be treated as v1 and re-seeded.
|
|
3064
|
+
*/
|
|
3065
|
+
const HAND_MAINTAINED_INDEX_VERSION = 2;
|
|
3066
|
+
const VERSION_FILENAME = ".version";
|
|
3067
|
+
const MEMORY_MIGRATIONS = [{
|
|
3068
|
+
from: 1,
|
|
3069
|
+
to: 2,
|
|
3070
|
+
apply: async ({ cwd }) => {
|
|
3071
|
+
await seedMemoryIndexFile({ cwd });
|
|
3072
|
+
}
|
|
3073
|
+
}];
|
|
3074
|
+
/**
|
|
3075
|
+
* The layout version of an agent's `.memory/`:
|
|
3076
|
+
* - `null` when there's no `.memory/` at all (a fresh agent is current by
|
|
3077
|
+
* construction; nothing to migrate).
|
|
3078
|
+
* - `1` when `.memory/` exists but carries no `.version` marker — i.e. it
|
|
3079
|
+
* predates versioning.
|
|
3080
|
+
* - otherwise the integer in `.memory/.version`.
|
|
3081
|
+
*/
|
|
3082
|
+
async function readMemoryVersion(cwd) {
|
|
3083
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3084
|
+
if (!(await stat(memoryDirAbs).catch(() => null))?.isDirectory()) return null;
|
|
3085
|
+
const raw = await readFile(join(memoryDirAbs, VERSION_FILENAME), "utf-8").catch(() => null);
|
|
3086
|
+
if (raw === null) return await stat(join(memoryDirAbs, MEMORY_INDEX_FILENAME)).then((s) => s.isFile()).catch(() => false) ? HAND_MAINTAINED_INDEX_VERSION : 1;
|
|
3087
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
3088
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
|
3089
|
+
}
|
|
3090
|
+
async function writeMemoryVersion(cwd, version) {
|
|
3091
|
+
await writeFile(join(cwd, MEMORY_DIRNAME, VERSION_FILENAME), `${version}\n`, "utf-8");
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Bring an agent's `.memory/` up to `CURRENT_MEMORY_VERSION` by applying the
|
|
3095
|
+
* ordered migrations. Runs on session start. No-op when there's no `.memory/`
|
|
3096
|
+
* yet or it's already current. Migrations must be idempotent, so a lost/unwritten
|
|
3097
|
+
* version marker (the file isn't committed by the harness) only costs a repeated
|
|
3098
|
+
* no-op, never corruption. Returns the `{ from, to }` actually applied, or
|
|
3099
|
+
* `null` when nothing ran.
|
|
3100
|
+
*/
|
|
3101
|
+
async function migrateMemory({ cwd }) {
|
|
3102
|
+
const from = await readMemoryVersion(cwd);
|
|
3103
|
+
if (from === null || from >= 2) return null;
|
|
3104
|
+
let version = from;
|
|
3105
|
+
while (version < 2) {
|
|
3106
|
+
const migration = MEMORY_MIGRATIONS.find((m) => m.from === version);
|
|
3107
|
+
if (!migration) break;
|
|
3108
|
+
await migration.apply({ cwd });
|
|
3109
|
+
version = migration.to;
|
|
3110
|
+
}
|
|
3111
|
+
await writeMemoryVersion(cwd, version);
|
|
3112
|
+
return {
|
|
3113
|
+
from,
|
|
3114
|
+
to: version
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
2465
3117
|
//#endregion
|
|
2466
3118
|
//#region src/extensions/memory.ts
|
|
2467
3119
|
const log$6 = logger.child({ module: "memory-extension" });
|
|
2468
3120
|
/**
|
|
2469
3121
|
* The standing instructions for the memory system. Always injected (even with
|
|
2470
|
-
*
|
|
3122
|
+
* no `MEMORY.md`) so the agent knows it can persist notes and how. `users/` is
|
|
2471
3123
|
* described by the platform memory extension, which is the only thing that can
|
|
2472
3124
|
* scope it to a person — here we just point at it.
|
|
2473
3125
|
*/
|
|
2474
3126
|
function memoryInstructions(cwd) {
|
|
2475
3127
|
return `## Memory across conversations
|
|
2476
3128
|
|
|
2477
|
-
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo
|
|
3129
|
+
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo, one fact per file. You maintain a hand-written index of them at \`${cwd}/.memory/MEMORY.md\`, and the harness injects that index into your system prompt every turn. **Bodies are NOT auto-loaded** — when an index line looks relevant, use your \`read\` tool to load that specific file.
|
|
2478
3130
|
|
|
2479
3131
|
Memory records **what happened**: facts you learned, events, investigation findings, project and system details worth carrying forward. It is NOT where behavior goes. A standing rule about how you should act — a "from now on, always/never …", a tone or format preference, a workflow convention a user wants you to follow — belongs in \`soul.md\` (see the Persona / Standing instructions section), not here. When a note is really an instruction about your behavior, write it to \`soul.md\`; when it is a fact or a record of something that occurred, write it here.
|
|
2480
3132
|
|
|
2481
|
-
Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are
|
|
3133
|
+
You own \`MEMORY.md\`. When you learn something durable, write the fact to its own \`.md\` file and add a one-line pointer to \`MEMORY.md\` in the form \`- [Title](relative/path.md) — one-line hook\`, where the hook is what tells future-you when to open the file. \`MEMORY.md\` is an *index*, never a store — put the actual content in the topic file and only a pointer line in \`MEMORY.md\`; do not inline a fact's body into the index even when it seems cheaper. Start each topic file with \`name:\`/\`description:\` frontmatter (the \`description\` is the one-line hook) so the index can be re-seeded, re-derived, or linted from the files themselves. When a fact changes, edit both the file and its line; when it stops being true, delete the file and its line. Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are indexed for you separately, scoped to whoever you're talking to — don't put people's private notes in the shared \`MEMORY.md\`.)
|
|
3134
|
+
|
|
3135
|
+
Keep \`MEMORY.md\` lean. It's re-injected on *every* turn, so a small, high-signal index is worth far more than an exhaustive one — curate it like a tightly-edited table of contents, not a log:
|
|
3136
|
+
- Be selective. Only record something durable that will matter in a *future* conversation. Don't record what only matters right now, what you can re-derive on demand, or what's already obvious from the repo.
|
|
3137
|
+
- Consolidate before you create. Before adding a line, scan \`MEMORY.md\` for one that already covers the topic; if it exists, \`read\` that file and rewrite it with the new facts merged in rather than adding a near-duplicate. One fact per file, but don't fragment a topic across many thin files.
|
|
3138
|
+
- Prune as you go. Delete lines (and their files) that are wrong, stale, or superseded. The index header reports its size — when it's flagged over budget, consolidate and delete before adding anything new.
|
|
3139
|
+
- Write dates absolute, not relative. Resolve "last week" / "yesterday" to a concrete date when you record it (e.g. "on 7/3 Dhruv told me to …"), so the note still reads correctly in a future conversation.
|
|
3140
|
+
|
|
3141
|
+
Commit and push after editing \`.memory/\` to persist it.`;
|
|
2482
3142
|
}
|
|
2483
3143
|
function composeBlock$1({ cwd, index }) {
|
|
2484
3144
|
const instructions = memoryInstructions(cwd);
|
|
2485
3145
|
if (!index || index.length === 0) return instructions;
|
|
2486
|
-
|
|
3146
|
+
const header = `## Memory index (${indexSizeNote(index)})`;
|
|
3147
|
+
const warning = indexBudgetWarning(index);
|
|
3148
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3149
|
+
return `${instructions}\n\n${warning ? `${header}\n\n${warning}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2487
3150
|
}
|
|
2488
3151
|
const memoryExtension = (pi) => {
|
|
2489
3152
|
let cachedBlock = null;
|
|
2490
3153
|
pi.on("session_start", async (_event, ctx) => {
|
|
2491
3154
|
try {
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
3155
|
+
const migrated = await migrateMemory({ cwd: ctx.cwd });
|
|
3156
|
+
if (migrated !== null) log$6.info({
|
|
3157
|
+
event: "memory_migrated",
|
|
3158
|
+
from: migrated.from,
|
|
3159
|
+
to: migrated.to
|
|
3160
|
+
}, "migrated memory layout to current version");
|
|
3161
|
+
} catch (err) {
|
|
3162
|
+
log$6.warn({
|
|
3163
|
+
err,
|
|
3164
|
+
event: "memory_migration_failed"
|
|
3165
|
+
}, "memory migration failed; continuing with existing index");
|
|
3166
|
+
}
|
|
3167
|
+
try {
|
|
3168
|
+
const index = await readMemoryIndexFile({ cwd: ctx.cwd });
|
|
2496
3169
|
cachedBlock = composeBlock$1({
|
|
2497
3170
|
cwd: ctx.cwd,
|
|
2498
3171
|
index
|
|
@@ -2501,7 +3174,7 @@ const memoryExtension = (pi) => {
|
|
|
2501
3174
|
log$6.warn({
|
|
2502
3175
|
err,
|
|
2503
3176
|
event: "memory_index_failed"
|
|
2504
|
-
}, "memory index
|
|
3177
|
+
}, "memory index read failed; injecting instructions only");
|
|
2505
3178
|
cachedBlock = memoryInstructions(ctx.cwd);
|
|
2506
3179
|
}
|
|
2507
3180
|
});
|
|
@@ -2560,9 +3233,12 @@ function slugifyName(name) {
|
|
|
2560
3233
|
function composeBlock({ index, user }) {
|
|
2561
3234
|
const instructions = `## Current user memory
|
|
2562
3235
|
|
|
2563
|
-
Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory.`;
|
|
3236
|
+
Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory. Keep it lean and be selective — this is re-injected every turn; consolidate related facts into one file and delete what's stale rather than piling on near-duplicates.`;
|
|
2564
3237
|
if (!index || index.length === 0) return instructions;
|
|
2565
|
-
|
|
3238
|
+
const warning = indexBudgetWarning(index);
|
|
3239
|
+
const header = `Memory index (${indexSizeNote(index)}):`;
|
|
3240
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3241
|
+
return `${instructions}\n\n${warning ? `${warning}\n\n${header}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2566
3242
|
}
|
|
2567
3243
|
/**
|
|
2568
3244
|
* Build the platform memory extension. `channelContext` is the per-turn ref
|
|
@@ -2583,10 +3259,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2583
3259
|
cachedBlock = composeBlock({
|
|
2584
3260
|
index: await buildMemoryIndex({
|
|
2585
3261
|
cwd: ctx.cwd,
|
|
2586
|
-
|
|
2587
|
-
kind: "user",
|
|
2588
|
-
userId: user.id
|
|
2589
|
-
}
|
|
3262
|
+
userId: user.id
|
|
2590
3263
|
}),
|
|
2591
3264
|
user
|
|
2592
3265
|
});
|
|
@@ -2782,7 +3455,6 @@ const soulExtension = (pi) => {
|
|
|
2782
3455
|
//#endregion
|
|
2783
3456
|
//#region src/extensions/subagent/index.ts
|
|
2784
3457
|
const log$2 = logger.child({ module: "subagent-ext" });
|
|
2785
|
-
const COLD_START_FLAG_WAIT_MS = 750;
|
|
2786
3458
|
const MAX_TASKS = 8;
|
|
2787
3459
|
const TaskItem = Type.Object({
|
|
2788
3460
|
task: Type.String({ description: "The task to delegate to a subagent run." }),
|
|
@@ -2791,8 +3463,14 @@ const TaskItem = Type.Object({
|
|
|
2791
3463
|
maxLength: 120
|
|
2792
3464
|
}),
|
|
2793
3465
|
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." })),
|
|
2794
|
-
model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on
|
|
3466
|
+
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." })),
|
|
3467
|
+
timeoutMinutes: Type.Optional(Type.Integer({
|
|
3468
|
+
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.",
|
|
3469
|
+
minimum: 1,
|
|
3470
|
+
maximum: 360
|
|
3471
|
+
}))
|
|
2795
3472
|
});
|
|
3473
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
|
|
2796
3474
|
const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
|
|
2797
3475
|
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.",
|
|
2798
3476
|
minItems: 1,
|
|
@@ -2807,7 +3485,9 @@ function buildTool(messageId) {
|
|
|
2807
3485
|
"Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
|
|
2808
3486
|
"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.",
|
|
2809
3487
|
"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.",
|
|
2810
|
-
"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
|
|
3488
|
+
"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.",
|
|
3489
|
+
"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.",
|
|
3490
|
+
"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."
|
|
2811
3491
|
].join(" "),
|
|
2812
3492
|
promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
|
|
2813
3493
|
parameters: SubagentParams,
|
|
@@ -2825,24 +3505,35 @@ function buildTool(messageId) {
|
|
|
2825
3505
|
task: t.task,
|
|
2826
3506
|
title: t.title ?? null,
|
|
2827
3507
|
persona: t.persona ?? null,
|
|
2828
|
-
model: t.model ?? null
|
|
3508
|
+
model: t.model ?? null,
|
|
3509
|
+
timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
|
|
2829
3510
|
}));
|
|
2830
3511
|
try {
|
|
2831
|
-
const
|
|
3512
|
+
const spawned = await postSubagentSpawn({
|
|
2832
3513
|
messageId,
|
|
2833
3514
|
tasks: spawnTasks
|
|
2834
3515
|
});
|
|
3516
|
+
const { taskIds } = spawned;
|
|
2835
3517
|
log$2.info({
|
|
2836
3518
|
event: "subagent_spawned",
|
|
2837
3519
|
count: taskIds.length
|
|
2838
3520
|
}, "subagent tasks queued");
|
|
2839
|
-
const
|
|
3521
|
+
const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
|
|
3522
|
+
const lines = taskIds.map((id, i) => {
|
|
3523
|
+
const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
|
|
3524
|
+
const conv = convByTask.get(id);
|
|
3525
|
+
return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
|
|
3526
|
+
}).join("\n");
|
|
3527
|
+
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." : "";
|
|
2840
3528
|
return {
|
|
2841
3529
|
content: [{
|
|
2842
3530
|
type: "text",
|
|
2843
|
-
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}`
|
|
3531
|
+
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}`
|
|
2844
3532
|
}],
|
|
2845
|
-
details: {
|
|
3533
|
+
details: {
|
|
3534
|
+
taskIds,
|
|
3535
|
+
tasks: spawned.tasks
|
|
3536
|
+
}
|
|
2846
3537
|
};
|
|
2847
3538
|
} catch (err) {
|
|
2848
3539
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2863,16 +3554,15 @@ function buildTool(messageId) {
|
|
|
2863
3554
|
};
|
|
2864
3555
|
}
|
|
2865
3556
|
/**
|
|
2866
|
-
* Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
|
|
2867
3557
|
* The factory takes the session's channel context to resolve the originating
|
|
2868
3558
|
* messageId — the api links each spawned run to the conversation that message
|
|
2869
3559
|
* belongs to and rewakes it on completion (nothing about the parent is piped
|
|
2870
|
-
* from the sandbox beyond that id).
|
|
3560
|
+
* from the sandbox beyond that id). The tool is registered unconditionally at
|
|
3561
|
+
* session_start.
|
|
2871
3562
|
*/
|
|
2872
3563
|
function createSubagentExtension({ channelContext }) {
|
|
2873
3564
|
return (pi) => {
|
|
2874
3565
|
const messageId = extractMessageId(channelContext);
|
|
2875
|
-
startFeatureFlagPoller();
|
|
2876
3566
|
let registered = false;
|
|
2877
3567
|
const registerOnce = () => {
|
|
2878
3568
|
if (registered) return;
|
|
@@ -2880,12 +3570,8 @@ function createSubagentExtension({ channelContext }) {
|
|
|
2880
3570
|
pi.registerTool(buildTool(messageId));
|
|
2881
3571
|
log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
|
|
2882
3572
|
};
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
});
|
|
2886
|
-
pi.on("session_start", async () => {
|
|
2887
|
-
if (getPolledFlag("subagent") === null) await Promise.race([awaitFirstFlagPoll(), new Promise((resolve) => setTimeout(resolve, COLD_START_FLAG_WAIT_MS).unref?.())]);
|
|
2888
|
-
if (getPolledFlag("subagent") === true) registerOnce();
|
|
3573
|
+
pi.on("session_start", () => {
|
|
3574
|
+
registerOnce();
|
|
2889
3575
|
});
|
|
2890
3576
|
};
|
|
2891
3577
|
}
|
|
@@ -3163,8 +3849,14 @@ const toolCallSummaryExtension = (pi) => {
|
|
|
3163
3849
|
* from the origin messageId in its channel context (`resolveConversationFromApi`)
|
|
3164
3850
|
* — and every run of the same conversation resolves to the same id, keeping the
|
|
3165
3851
|
* shared map correctly scoped across turns. `bg_*`, the completion wake, and the
|
|
3166
|
-
* next-session injection all filter
|
|
3167
|
-
*
|
|
3852
|
+
* next-session injection all filter by a **scope key** — the resolved
|
|
3853
|
+
* conversation id, or, when a session's conversation is unresolvable (a bare
|
|
3854
|
+
* CLI session, or a run whose channel-context ref carries no messageId), a
|
|
3855
|
+
* sentinel unique to that one session instance. Comparing on the raw
|
|
3856
|
+
* `conversationId` would bucket every unresolvable session together under
|
|
3857
|
+
* `null` and leak one's completion wake / status / next-session injection into
|
|
3858
|
+
* another; the sentinel keeps each isolated so an agent never sees or is woken
|
|
3859
|
+
* by a task from a different chat. Only the output log
|
|
3168
3860
|
* spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
|
|
3169
3861
|
* in memory; exit code and run state live on the in-memory task.
|
|
3170
3862
|
*
|
|
@@ -3188,6 +3880,7 @@ const WATCHDOG_INTERVAL_MS = 3e4;
|
|
|
3188
3880
|
const KEEPALIVE_EVERY_MS = 6e4;
|
|
3189
3881
|
const KEEPALIVE_MAX_MS = 3600 * 1e3;
|
|
3190
3882
|
const STALL_HINT_AFTER_MS = 120 * 1e3;
|
|
3883
|
+
const PUBLISH_DEBOUNCE_MS = 300;
|
|
3191
3884
|
const MAX_LOG_BYTES = 100 * 1024 * 1024;
|
|
3192
3885
|
const DEFAULT_TAIL_LINES = 30;
|
|
3193
3886
|
const TAIL_READ_BYTES = 64 * 1024;
|
|
@@ -3195,11 +3888,12 @@ function taskLabel(meta) {
|
|
|
3195
3888
|
return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
|
|
3196
3889
|
}
|
|
3197
3890
|
let taskCounter = 0;
|
|
3891
|
+
let sessionScopeCounter = 0;
|
|
3198
3892
|
const tasks = /* @__PURE__ */ new Map();
|
|
3199
3893
|
let watchdogInterval = null;
|
|
3200
3894
|
let lastKeepaliveAt = 0;
|
|
3201
|
-
function
|
|
3202
|
-
return meta.
|
|
3895
|
+
function sameScope(meta, scopeKey) {
|
|
3896
|
+
return meta.scopeKey === scopeKey;
|
|
3203
3897
|
}
|
|
3204
3898
|
function logPath(id) {
|
|
3205
3899
|
return join(tasksDir(), `${id}.log`);
|
|
@@ -3312,6 +4006,10 @@ function createBackgroundTasksExtension({ channelContext }) {
|
|
|
3312
4006
|
return id;
|
|
3313
4007
|
});
|
|
3314
4008
|
}
|
|
4009
|
+
const unresolvedScopeSentinel = `unresolved:${process.pid.toString(36)}:${(sessionScopeCounter += 1).toString(36)}`;
|
|
4010
|
+
function scopeKey() {
|
|
4011
|
+
return conversationId ?? unresolvedScopeSentinel;
|
|
4012
|
+
}
|
|
3315
4013
|
let agentActive = false;
|
|
3316
4014
|
pi.on("agent_start", async () => {
|
|
3317
4015
|
agentActive = true;
|
|
@@ -3345,7 +4043,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3345
4043
|
}
|
|
3346
4044
|
async function notifyCompletion(meta) {
|
|
3347
4045
|
if (meta.notified) return;
|
|
3348
|
-
if (agentActive &&
|
|
4046
|
+
if (agentActive && sameScope(meta, scopeKey())) {
|
|
3349
4047
|
meta.notified = true;
|
|
3350
4048
|
pi.sendMessage(await taskDoneMessage(meta), {
|
|
3351
4049
|
triggerTurn: true,
|
|
@@ -3376,6 +4074,80 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3376
4074
|
});
|
|
3377
4075
|
} else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
|
|
3378
4076
|
}
|
|
4077
|
+
const SNAPSHOT_TAIL_LINES = 40;
|
|
4078
|
+
let lastPublishedSignature = null;
|
|
4079
|
+
let publishInFlight = null;
|
|
4080
|
+
let publishQueued = false;
|
|
4081
|
+
function snapshotSignature(snapshot) {
|
|
4082
|
+
return JSON.stringify(snapshot.map((t) => ({
|
|
4083
|
+
id: t.id,
|
|
4084
|
+
state: t.state,
|
|
4085
|
+
exitCode: t.exitCode,
|
|
4086
|
+
killedReason: t.killedReason,
|
|
4087
|
+
outputTail: t.outputTail
|
|
4088
|
+
})));
|
|
4089
|
+
}
|
|
4090
|
+
async function doPublishSnapshot() {
|
|
4091
|
+
if (!messageId) return;
|
|
4092
|
+
const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
|
|
4093
|
+
try {
|
|
4094
|
+
const snapshot = await Promise.all(mine.map(async (t) => ({
|
|
4095
|
+
id: t.id,
|
|
4096
|
+
command: t.command,
|
|
4097
|
+
description: t.description,
|
|
4098
|
+
startedAt: t.startedAt,
|
|
4099
|
+
state: t.running ? "running" : "finished",
|
|
4100
|
+
exitCode: t.exitCode,
|
|
4101
|
+
killedReason: t.killedReason,
|
|
4102
|
+
outputTail: await tailLog(t.id, SNAPSHOT_TAIL_LINES)
|
|
4103
|
+
})));
|
|
4104
|
+
const signature = snapshotSignature(snapshot);
|
|
4105
|
+
if (signature === lastPublishedSignature) return;
|
|
4106
|
+
lastPublishedSignature = signature;
|
|
4107
|
+
postBackgroundTasksSnapshot({
|
|
4108
|
+
messageId,
|
|
4109
|
+
tasks: snapshot
|
|
4110
|
+
});
|
|
4111
|
+
} catch (err) {
|
|
4112
|
+
log.debug({
|
|
4113
|
+
err,
|
|
4114
|
+
event: "bg_tasks_snapshot_build_failed"
|
|
4115
|
+
}, "building bg-tasks snapshot failed");
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
async function publishSnapshotNow() {
|
|
4119
|
+
if (publishInFlight) {
|
|
4120
|
+
publishQueued = true;
|
|
4121
|
+
return;
|
|
4122
|
+
}
|
|
4123
|
+
publishInFlight = (async () => {
|
|
4124
|
+
try {
|
|
4125
|
+
do {
|
|
4126
|
+
publishQueued = false;
|
|
4127
|
+
await doPublishSnapshot();
|
|
4128
|
+
} while (publishQueued);
|
|
4129
|
+
} finally {
|
|
4130
|
+
publishInFlight = null;
|
|
4131
|
+
}
|
|
4132
|
+
})();
|
|
4133
|
+
await publishInFlight;
|
|
4134
|
+
}
|
|
4135
|
+
let publishTimer = null;
|
|
4136
|
+
function schedulePublishSnapshot() {
|
|
4137
|
+
if (publishTimer) return;
|
|
4138
|
+
publishTimer = setTimeout(() => {
|
|
4139
|
+
publishTimer = null;
|
|
4140
|
+
publishSnapshotNow();
|
|
4141
|
+
}, PUBLISH_DEBOUNCE_MS);
|
|
4142
|
+
publishTimer.unref?.();
|
|
4143
|
+
}
|
|
4144
|
+
async function flushPublishSnapshot() {
|
|
4145
|
+
if (publishTimer) {
|
|
4146
|
+
clearTimeout(publishTimer);
|
|
4147
|
+
publishTimer = null;
|
|
4148
|
+
}
|
|
4149
|
+
await publishSnapshotNow();
|
|
4150
|
+
}
|
|
3379
4151
|
async function launchTask({ command, description, cwd }) {
|
|
3380
4152
|
taskCounter += 1;
|
|
3381
4153
|
const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
|
|
@@ -3402,6 +4174,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3402
4174
|
logBytes: 0,
|
|
3403
4175
|
lastOutputAt: startedAt,
|
|
3404
4176
|
conversationId,
|
|
4177
|
+
scopeKey: scopeKey(),
|
|
3405
4178
|
messageId,
|
|
3406
4179
|
description,
|
|
3407
4180
|
notified: false,
|
|
@@ -3437,6 +4210,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3437
4210
|
taskId: id,
|
|
3438
4211
|
exitCode: meta.exitCode
|
|
3439
4212
|
}, "bg task finished");
|
|
4213
|
+
schedulePublishSnapshot();
|
|
3440
4214
|
await notifyCompletion(meta);
|
|
3441
4215
|
});
|
|
3442
4216
|
ensureWatchdog();
|
|
@@ -3444,19 +4218,20 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3444
4218
|
taskId: id,
|
|
3445
4219
|
conversationId
|
|
3446
4220
|
}, "bg task started");
|
|
4221
|
+
schedulePublishSnapshot();
|
|
3447
4222
|
return meta;
|
|
3448
4223
|
}
|
|
3449
4224
|
function knownTaskIds() {
|
|
3450
|
-
return [...tasks.values()].filter((t) =>
|
|
4225
|
+
return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
|
|
3451
4226
|
}
|
|
3452
4227
|
pi.on("session_start", async () => {
|
|
3453
4228
|
if (tasks.size === 0) return;
|
|
3454
4229
|
await ensureConversationId();
|
|
3455
|
-
for (const [id, meta] of tasks) if (
|
|
4230
|
+
for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
|
|
3456
4231
|
tasks.delete(id);
|
|
3457
4232
|
await unlink(logPath(id)).catch(() => {});
|
|
3458
4233
|
}
|
|
3459
|
-
const unnotified = [...tasks.values()].filter((t) =>
|
|
4234
|
+
const unnotified = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && !t.notified && !t.running);
|
|
3460
4235
|
for (const meta of unnotified) {
|
|
3461
4236
|
meta.notified = true;
|
|
3462
4237
|
pi.sendMessage(await taskDoneMessage(meta));
|
|
@@ -3465,7 +4240,9 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3465
4240
|
conversationId,
|
|
3466
4241
|
count: unnotified.length
|
|
3467
4242
|
}, "injected completed bg tasks at session_start");
|
|
3468
|
-
if ([...tasks.values()].some((t) =>
|
|
4243
|
+
if ([...tasks.values()].some((t) => sameScope(t, scopeKey()) && t.running)) ensureWatchdog();
|
|
4244
|
+
lastPublishedSignature = null;
|
|
4245
|
+
await flushPublishSnapshot();
|
|
3469
4246
|
});
|
|
3470
4247
|
function err(text) {
|
|
3471
4248
|
return {
|
|
@@ -3482,11 +4259,11 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3482
4259
|
}
|
|
3483
4260
|
function resolveTask(taskId) {
|
|
3484
4261
|
const exact = tasks.get(taskId);
|
|
3485
|
-
if (exact &&
|
|
4262
|
+
if (exact && sameScope(exact, scopeKey())) return {
|
|
3486
4263
|
error: null,
|
|
3487
4264
|
meta: exact
|
|
3488
4265
|
};
|
|
3489
|
-
const matches = [...tasks.values()].filter((t) =>
|
|
4266
|
+
const matches = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && t.id.startsWith(taskId));
|
|
3490
4267
|
if (matches.length === 1) return {
|
|
3491
4268
|
error: null,
|
|
3492
4269
|
meta: matches[0]
|
|
@@ -3495,7 +4272,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3495
4272
|
return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
|
|
3496
4273
|
}
|
|
3497
4274
|
function listTasks() {
|
|
3498
|
-
const mine = [...tasks.values()].filter((t) =>
|
|
4275
|
+
const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
|
|
3499
4276
|
if (mine.length === 0) return "No background tasks.";
|
|
3500
4277
|
return mine.map((t) => {
|
|
3501
4278
|
const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
|
|
@@ -3645,6 +4422,7 @@ const all = [
|
|
|
3645
4422
|
localToolsExtension,
|
|
3646
4423
|
toolCallEnvExtension,
|
|
3647
4424
|
bashDefaultTimeoutExtension,
|
|
4425
|
+
diskGuardExtension,
|
|
3648
4426
|
toolCallSummaryExtension
|
|
3649
4427
|
];
|
|
3650
4428
|
/**
|
|
@@ -3663,7 +4441,8 @@ function platformExtensions({ sessionId, channelContext }) {
|
|
|
3663
4441
|
selfTraceExtension,
|
|
3664
4442
|
createBackgroundTasksExtension({ channelContext }),
|
|
3665
4443
|
createSubagentExtension({ channelContext }),
|
|
3666
|
-
createContextManagementExtension()
|
|
4444
|
+
createContextManagementExtension(),
|
|
4445
|
+
resourcePressureWarningExtension
|
|
3667
4446
|
];
|
|
3668
4447
|
}
|
|
3669
4448
|
//#endregion
|