@skydiveai/pi-extensions 0.1.0
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/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/index.d.mts +163 -0
- package/dist/index.mjs +3626 -0
- package/package.json +70 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3626 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
|
|
3
|
+
import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
|
|
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";
|
|
6
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
13
|
+
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
14
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
15
|
+
import { Check, Errors } from "typebox/value";
|
|
16
|
+
import { ROOT_CONTEXT, SpanStatusCode, propagation, trace } from "@opentelemetry/api";
|
|
17
|
+
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
18
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
19
|
+
import { Resource } from "@opentelemetry/resources";
|
|
20
|
+
import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
21
|
+
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
22
|
+
import { hc } from "hono/client";
|
|
23
|
+
import { parse } from "yaml";
|
|
24
|
+
import { createWriteStream } from "node:fs";
|
|
25
|
+
import { finished } from "node:stream/promises";
|
|
26
|
+
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
//#region src/platform-env-middleware.ts
|
|
28
|
+
const ENVD_TOKEN_HEADER = "x-e2b-envd-token";
|
|
29
|
+
const ENVD_URL = "http://localhost:49983/envs";
|
|
30
|
+
const DAEMON_ENV_URL = "http://localhost:38994/v1/env";
|
|
31
|
+
const DAEMON_SET_ENVD_URL = "http://localhost:38994/v1/set-envd";
|
|
32
|
+
/**
|
|
33
|
+
* Identity env the daemon's `/v1/rebind` can swap when a warm pooled sandbox is
|
|
34
|
+
* claimed for a new agent. e2b envd (see below) is a create-time snapshot and
|
|
35
|
+
* can NEVER reflect a rebind, so these keys must come from the daemon store,
|
|
36
|
+
* which rebind mutates. Kept in sync with the daemon's REBINDABLE_KEYS.
|
|
37
|
+
*/
|
|
38
|
+
const REBINDABLE_IDENTITY_KEYS = [
|
|
39
|
+
"ANYONE_AGENT_ID",
|
|
40
|
+
"ANYONE_SANDBOX_TOKEN",
|
|
41
|
+
"SKYDIVE_AGENT_ID",
|
|
42
|
+
"SKYDIVE_SANDBOX_TOKEN"
|
|
43
|
+
];
|
|
44
|
+
let loaded = false;
|
|
45
|
+
function isPlatformConfigLoaded() {
|
|
46
|
+
return loaded;
|
|
47
|
+
}
|
|
48
|
+
async function loadPlatformConfig(request) {
|
|
49
|
+
if (loaded) return;
|
|
50
|
+
const token = request.headers.get(ENVD_TOKEN_HEADER) ?? void 0;
|
|
51
|
+
if (token) {
|
|
52
|
+
const envs = await fetchEnvdDirect(token);
|
|
53
|
+
if (envs && Object.keys(envs).length > 0) {
|
|
54
|
+
for (const [k, v] of Object.entries(envs)) process.env[k] = v;
|
|
55
|
+
const reboundKeys = await overlayReboundIdentity(token);
|
|
56
|
+
loaded = true;
|
|
57
|
+
logger.info({
|
|
58
|
+
event: "platform_config_loaded",
|
|
59
|
+
source: "envd",
|
|
60
|
+
keys: Object.keys(envs).length,
|
|
61
|
+
reboundKeys
|
|
62
|
+
}, "platform config loaded from envd");
|
|
63
|
+
setTimeout(() => pushToDaemon(token), 0);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const envs = await fetchDaemonEnv(token);
|
|
68
|
+
if (envs) {
|
|
69
|
+
for (const [k, v] of Object.entries(envs)) process.env[k] = v;
|
|
70
|
+
loaded = true;
|
|
71
|
+
logger.info({
|
|
72
|
+
event: "platform_config_loaded",
|
|
73
|
+
source: "daemon",
|
|
74
|
+
keys: Object.keys(envs).length
|
|
75
|
+
}, "platform config loaded from daemon");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
logger.warn({ event: "platform_config_missing" }, "no platform config available");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Overlay the rebindable identity keys from the daemon env store onto
|
|
82
|
+
* `process.env`, so a claimed warm-pool sandbox resolves the claiming agent's
|
|
83
|
+
* identity rather than the create-time pool identity that e2b envd still holds.
|
|
84
|
+
* The daemon store is authoritative for these keys because `/v1/rebind` mutates
|
|
85
|
+
* it (and it persists across the harness restart the claim path performs).
|
|
86
|
+
* Returns the number of keys actually changed (0 on a fresh, never-rebound box).
|
|
87
|
+
*/
|
|
88
|
+
async function overlayReboundIdentity(token) {
|
|
89
|
+
const daemonEnv = await fetchDaemonEnv(token);
|
|
90
|
+
if (!daemonEnv) return 0;
|
|
91
|
+
let changed = 0;
|
|
92
|
+
for (const key of REBINDABLE_IDENTITY_KEYS) {
|
|
93
|
+
const value = daemonEnv[key];
|
|
94
|
+
if (typeof value === "string" && value && process.env[key] !== value) {
|
|
95
|
+
process.env[key] = value;
|
|
96
|
+
changed += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return changed;
|
|
100
|
+
}
|
|
101
|
+
async function fetchEnvdDirect(token) {
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(ENVD_URL, { headers: { "X-Access-Token": token } });
|
|
104
|
+
if (!res.ok) return null;
|
|
105
|
+
const raw = await res.json();
|
|
106
|
+
if (!raw || typeof raw !== "object") return null;
|
|
107
|
+
const data = {};
|
|
108
|
+
for (const [k, v] of Object.entries(raw)) if (typeof v === "string") data[k] = v;
|
|
109
|
+
return data;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
logger.error({ err: error }, "Failed to fetch envd direct");
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function pushToDaemon(token) {
|
|
116
|
+
fetch(DAEMON_SET_ENVD_URL, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: {
|
|
119
|
+
"x-envd-token": token,
|
|
120
|
+
"Content-Type": "application/json"
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify({ sandboxId: process.env["E2B_SANDBOX_ID"] ?? "" })
|
|
123
|
+
}).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
async function fetchDaemonEnv(token) {
|
|
126
|
+
try {
|
|
127
|
+
const headers = {};
|
|
128
|
+
if (token) headers["x-envd-token"] = token;
|
|
129
|
+
const res = await fetch(DAEMON_ENV_URL, { headers });
|
|
130
|
+
if (!res.ok) return null;
|
|
131
|
+
return await res.json();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.error({ err: error }, "Failed to fetch daemon env");
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/versions.ts
|
|
139
|
+
/**
|
|
140
|
+
* Installed versions of the Skydive platform packages — the first-party
|
|
141
|
+
* packages the harness process loads (the pi server and these extensions),
|
|
142
|
+
* which we publish and bump. When they change, the agent is running different
|
|
143
|
+
* platform code. Surfaced via GET /health so a stale runtime is observable
|
|
144
|
+
* from outside a sandbox without shelling in (and so the self-upgrade flow can
|
|
145
|
+
* confirm an install took effect).
|
|
146
|
+
*/
|
|
147
|
+
const require = createRequire(import.meta.url);
|
|
148
|
+
const PLATFORM_PACKAGES = ["@skydiveai/pi-server", "@skydiveai/pi-extensions"];
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a package's own package.json by resolving its entry point and walking
|
|
151
|
+
* up to the nearest package.json whose `name` matches. We can't resolve
|
|
152
|
+
* `${pkg}/package.json` directly — these packages' `exports` don't expose it.
|
|
153
|
+
*/
|
|
154
|
+
async function readPackageVersion(pkg) {
|
|
155
|
+
let dir;
|
|
156
|
+
try {
|
|
157
|
+
dir = dirname(require.resolve(pkg));
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
let current = dir;
|
|
162
|
+
while (true) {
|
|
163
|
+
try {
|
|
164
|
+
const manifest = JSON.parse(await readFile(resolve(current, "package.json"), "utf8"));
|
|
165
|
+
if (manifest.name === pkg && typeof manifest.version === "string") return manifest.version;
|
|
166
|
+
} catch {}
|
|
167
|
+
const parent = dirname(current);
|
|
168
|
+
if (parent === current) return null;
|
|
169
|
+
current = parent;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Map of platform package name -> installed version. Packages that can't be
|
|
174
|
+
* resolved are omitted (running from source, partial install) rather than
|
|
175
|
+
* reported as a bogus version.
|
|
176
|
+
*/
|
|
177
|
+
async function readPlatformVersions() {
|
|
178
|
+
const versions = {};
|
|
179
|
+
await Promise.all(PLATFORM_PACKAGES.map(async (pkg) => {
|
|
180
|
+
const version = await readPackageVersion(pkg);
|
|
181
|
+
if (version) versions[pkg] = version;
|
|
182
|
+
}));
|
|
183
|
+
return versions;
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/platform-middleware.ts
|
|
187
|
+
/**
|
|
188
|
+
* Express-mountable platform middleware: Skydive env injection and the
|
|
189
|
+
* health endpoint. Built on @skydiveai/pi-server's node:http bridge
|
|
190
|
+
* helpers so the express app itself stays in the agent's workspace.
|
|
191
|
+
*/
|
|
192
|
+
let platformVersionsPromise = null;
|
|
193
|
+
function platformVersions() {
|
|
194
|
+
return platformVersionsPromise ??= readPlatformVersions();
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Middleware that loads platform config (e2b envd / daemon env) before
|
|
198
|
+
* requests reach the protocol handlers. Builds a headers-only Request —
|
|
199
|
+
* the body stream must stay untouched for the downstream handlers.
|
|
200
|
+
* Mount above everything except /health; it can wait up to 10s for env
|
|
201
|
+
* vars during boot.
|
|
202
|
+
*/
|
|
203
|
+
function createPlatformEnvMiddleware() {
|
|
204
|
+
return async (req, _res, next) => {
|
|
205
|
+
try {
|
|
206
|
+
await loadPlatformConfig(new Request(requestUrl(req), { headers: requestHeaders(req) }));
|
|
207
|
+
} catch {}
|
|
208
|
+
next();
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* GET /health handler: platform health data plus whatever the agent's
|
|
213
|
+
* `metadata` callback returns. Must respond immediately (readiness
|
|
214
|
+
* probes, prewarm stashing) — mount it above the platform env
|
|
215
|
+
* middleware.
|
|
216
|
+
*/
|
|
217
|
+
function createHealthHandler({ metadata }) {
|
|
218
|
+
return async (_req, res) => {
|
|
219
|
+
let meta;
|
|
220
|
+
try {
|
|
221
|
+
meta = metadata?.() ?? void 0;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logger.error({
|
|
224
|
+
err,
|
|
225
|
+
event: "health_metadata_failed"
|
|
226
|
+
}, "health metadata callback threw");
|
|
227
|
+
}
|
|
228
|
+
const runtimeVersions = await platformVersions();
|
|
229
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
230
|
+
res.end(JSON.stringify({
|
|
231
|
+
ok: true,
|
|
232
|
+
uptime: process.uptime(),
|
|
233
|
+
platformConfigLoaded: isPlatformConfigLoaded(),
|
|
234
|
+
runtimeVersions,
|
|
235
|
+
...meta ? { metadata: meta } : {}
|
|
236
|
+
}));
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/extensions/context-management-config.ts
|
|
241
|
+
/**
|
|
242
|
+
* Configuration for the context-management capability (tool-output trimming
|
|
243
|
+
* and client-side microcompact). See the design notes in the extension for
|
|
244
|
+
* what each layer does; this module is purely the env → config surface.
|
|
245
|
+
*
|
|
246
|
+
* The harness is provider-agnostic and runs inside the sandbox, where its
|
|
247
|
+
* only config channel is the environment (loaded by the platform env
|
|
248
|
+
* middleware before any session starts — see platform-env-middleware.ts). So
|
|
249
|
+
* the LaunchDarkly flag `harness-context-management-enabled` is resolved by
|
|
250
|
+
* the platform when it provisions the sandbox and passed through as
|
|
251
|
+
* `SKYDIVE_CONTEXT_MANAGEMENT`; the individual knobs override the defaults
|
|
252
|
+
* below when present.
|
|
253
|
+
*
|
|
254
|
+
* Resolution is defensive: a malformed value never throws (this config is
|
|
255
|
+
* read on the hot path before every LLM call), it falls back to the default
|
|
256
|
+
* for that knob and logs once.
|
|
257
|
+
*/
|
|
258
|
+
const log$13 = logger.child({ module: "context-management-config" });
|
|
259
|
+
const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
|
|
260
|
+
enabled: false,
|
|
261
|
+
perResultMaxBytes: 16 * 1024,
|
|
262
|
+
keepRecentToolResults: 3,
|
|
263
|
+
coldCacheGapSeconds: 240,
|
|
264
|
+
warmClearTriggerTokens: 5e4,
|
|
265
|
+
clearAtLeastTokens: 1e4,
|
|
266
|
+
excludeTools: [],
|
|
267
|
+
maxModelCallsPerTurn: 80,
|
|
268
|
+
nativeAnthropicEdits: false
|
|
269
|
+
};
|
|
270
|
+
const boolFromEnv = (value, fallback) => {
|
|
271
|
+
if (value === void 0) return fallback;
|
|
272
|
+
const normalized = value.trim().toLowerCase();
|
|
273
|
+
if (normalized === "1" || normalized === "true") return true;
|
|
274
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
275
|
+
return fallback;
|
|
276
|
+
};
|
|
277
|
+
const positiveInt = (fallback) => z.coerce.number().int().positive().catch(fallback);
|
|
278
|
+
const nonNegativeInt = (fallback) => z.coerce.number().int().nonnegative().catch(fallback);
|
|
279
|
+
const configSchema = z.object({
|
|
280
|
+
perResultMaxBytes: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.perResultMaxBytes),
|
|
281
|
+
keepRecentToolResults: nonNegativeInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.keepRecentToolResults),
|
|
282
|
+
coldCacheGapSeconds: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.coldCacheGapSeconds),
|
|
283
|
+
warmClearTriggerTokens: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.warmClearTriggerTokens),
|
|
284
|
+
clearAtLeastTokens: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.clearAtLeastTokens),
|
|
285
|
+
maxModelCallsPerTurn: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.maxModelCallsPerTurn)
|
|
286
|
+
});
|
|
287
|
+
const parseExcludeTools = (value) => {
|
|
288
|
+
if (!value) return DEFAULT_CONTEXT_MANAGEMENT_CONFIG.excludeTools;
|
|
289
|
+
return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* Reads the context-management config from `env` (defaults to `process.env`).
|
|
293
|
+
* Never throws — invalid values fall back to defaults.
|
|
294
|
+
*/
|
|
295
|
+
function resolveContextManagementConfig(env = process.env) {
|
|
296
|
+
if (!boolFromEnv(env.SKYDIVE_CONTEXT_MANAGEMENT, false)) return { ...DEFAULT_CONTEXT_MANAGEMENT_CONFIG };
|
|
297
|
+
const parsed = configSchema.safeParse({
|
|
298
|
+
perResultMaxBytes: env.SKYDIVE_CTX_PER_RESULT_MAX_BYTES,
|
|
299
|
+
keepRecentToolResults: env.SKYDIVE_CTX_KEEP_RECENT,
|
|
300
|
+
coldCacheGapSeconds: env.SKYDIVE_CTX_COLD_GAP_SECONDS,
|
|
301
|
+
warmClearTriggerTokens: env.SKYDIVE_CTX_WARM_TRIGGER_TOKENS,
|
|
302
|
+
clearAtLeastTokens: env.SKYDIVE_CTX_CLEAR_AT_LEAST_TOKENS,
|
|
303
|
+
maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
|
|
304
|
+
});
|
|
305
|
+
if (!parsed.success) {
|
|
306
|
+
log$13.warn({
|
|
307
|
+
event: "context_management_config_invalid",
|
|
308
|
+
err: parsed.error
|
|
309
|
+
}, "falling back to default context-management config");
|
|
310
|
+
return {
|
|
311
|
+
...DEFAULT_CONTEXT_MANAGEMENT_CONFIG,
|
|
312
|
+
enabled: true
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
enabled: true,
|
|
317
|
+
...parsed.data,
|
|
318
|
+
excludeTools: parseExcludeTools(env.SKYDIVE_CTX_EXCLUDE_TOOLS),
|
|
319
|
+
nativeAnthropicEdits: boolFromEnv(env.SKYDIVE_CTX_NATIVE_ANTHROPIC_EDITS, DEFAULT_CONTEXT_MANAGEMENT_CONFIG.nativeAnthropicEdits)
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/extensions/context-management-runtime.ts
|
|
324
|
+
/**
|
|
325
|
+
* Live, mutable view of the context-management config.
|
|
326
|
+
*
|
|
327
|
+
* The env-derived config (context-management-config.ts) is the boot-time
|
|
328
|
+
* default. On top of it, the platform can deliver a global on/off at runtime
|
|
329
|
+
* via the LaunchDarkly flag `harness-context-management-enabled` — resolved
|
|
330
|
+
* server-side and polled by the harness (see the poller in
|
|
331
|
+
* context-management.ts). This holder is where that override lands so a flip
|
|
332
|
+
* (especially a kill-switch) reaches long-lived sandboxes without a restart.
|
|
333
|
+
*
|
|
334
|
+
* Only the master `enabled` toggle is overridable at runtime; the per-knob
|
|
335
|
+
* tunables stay env-derived. `enabled` resolves to the flag override when the
|
|
336
|
+
* platform has reported one, else the env value.
|
|
337
|
+
*/
|
|
338
|
+
let baseConfig = null;
|
|
339
|
+
let flagOverride = null;
|
|
340
|
+
function base() {
|
|
341
|
+
baseConfig ??= resolveContextManagementConfig();
|
|
342
|
+
return baseConfig;
|
|
343
|
+
}
|
|
344
|
+
/** The effective config, with the runtime flag override applied to `enabled`. */
|
|
345
|
+
function getContextManagementConfig() {
|
|
346
|
+
const resolved = base();
|
|
347
|
+
return {
|
|
348
|
+
...resolved,
|
|
349
|
+
enabled: flagOverride ?? resolved.enabled
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Apply the platform-reported flag value. `null` clears the override (fall back
|
|
354
|
+
* to the env default) — used when the phone-home result is indeterminate so a
|
|
355
|
+
* transient failure never silently changes behavior.
|
|
356
|
+
*/
|
|
357
|
+
function setContextManagementFlagOverride(enabled) {
|
|
358
|
+
flagOverride = enabled;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Whether a phone-home channel exists to learn the flag at runtime. When false
|
|
362
|
+
* (e.g. a bare local CLI with no platform API), the env value is the only
|
|
363
|
+
* source and there's nothing to poll.
|
|
364
|
+
*/
|
|
365
|
+
function hasFlagSource(env = process.env) {
|
|
366
|
+
return Boolean(env.SKYDIVE_API_URL ?? env.ANYONE_API_URL);
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/iteration-cap.ts
|
|
370
|
+
function installIterationCap({ session, log }, configOverride = null) {
|
|
371
|
+
const readConfig = () => configOverride ?? getContextManagementConfig();
|
|
372
|
+
if (!readConfig().enabled && (configOverride !== null || !hasFlagSource())) return;
|
|
373
|
+
const agent = session.agent;
|
|
374
|
+
if (typeof agent.createLoopConfig !== "function") throw new Error("installIterationCap: session.agent.createLoopConfig is missing — pi-agent-core internals changed; update iteration-cap.ts.");
|
|
375
|
+
const original = agent.createLoopConfig.bind(agent);
|
|
376
|
+
agent.createLoopConfig = (options) => {
|
|
377
|
+
const loopConfig = original(options);
|
|
378
|
+
const previousStop = loopConfig.shouldStopAfterTurn;
|
|
379
|
+
let modelCalls = 0;
|
|
380
|
+
return {
|
|
381
|
+
...loopConfig,
|
|
382
|
+
shouldStopAfterTurn: async (ctx) => {
|
|
383
|
+
if (previousStop && await previousStop(ctx)) return true;
|
|
384
|
+
const config = readConfig();
|
|
385
|
+
if (!config.enabled) return false;
|
|
386
|
+
modelCalls += 1;
|
|
387
|
+
if (modelCalls >= config.maxModelCallsPerTurn) {
|
|
388
|
+
log.warn({
|
|
389
|
+
event: "iteration_cap_reached",
|
|
390
|
+
modelCalls,
|
|
391
|
+
max: config.maxModelCallsPerTurn
|
|
392
|
+
}, "reached per-turn model-call cap; stopping turn gracefully");
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/extensions/capability-soul-nudge.ts
|
|
402
|
+
/**
|
|
403
|
+
* One-line reminder appended to a tool-update continuation when the agent
|
|
404
|
+
* gains a *new* capability (a freshly-loaded tool file or a newly-connected
|
|
405
|
+
* MCP server — not a refresh or removal). It points the agent back at
|
|
406
|
+
* `soul.md`, which the soul extension frames as the durable home of identity
|
|
407
|
+
* *and* capabilities. Without this, an added capability lives only in the
|
|
408
|
+
* mechanical tool/skill inventory and never becomes part of the self-concept
|
|
409
|
+
* a future conversation starts from — the "agent forgets what it can do"
|
|
410
|
+
* gap.
|
|
411
|
+
*
|
|
412
|
+
* Phrased to defer ("once the current task is done") so it nudges reflection
|
|
413
|
+
* without derailing the in-flight turn into a soul edit.
|
|
414
|
+
*/
|
|
415
|
+
const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task is done, if this changes what you can do for the user, record it in `soul.md` so it carries into future conversations rather than being rediscovered from scratch (then commit and push).";
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/extensions/local-tools.ts
|
|
418
|
+
/**
|
|
419
|
+
* Local-tools adapter as a pi extension. Mirrors the mcp.ts hot-reload pattern
|
|
420
|
+
* one level over: walks `tools/` (relative to the harness cwd), imports each
|
|
421
|
+
* module, and registers the exported `ToolDefinition`s with pi.
|
|
422
|
+
*
|
|
423
|
+
* The agent edits files under `tools/` between turns; on every `tool_result`
|
|
424
|
+
* the extension stat()s the directory's children, compares mtimes against
|
|
425
|
+
* what we last reconciled against, and queues a `pendingLocalToolsUpdate`
|
|
426
|
+
* if anything was added/removed/changed. The chat handler drains that queue
|
|
427
|
+
* after the current `session.prompt(...)` returns, calls `session.reload()`,
|
|
428
|
+
* and injects a synthetic continuation message — same dance as MCP.
|
|
429
|
+
*
|
|
430
|
+
* **ESM cache-busting.** `import(url)` in Node's ESM loader keys cached
|
|
431
|
+
* modules by URL. Re-importing the same path after editing the file gets
|
|
432
|
+
* the *original* module back. To force a fresh load on mtime change we
|
|
433
|
+
* append `?v=<mtimeMs>` to the URL — different URL, fresh module
|
|
434
|
+
* evaluation. The old version stays in memory but is unreachable.
|
|
435
|
+
*
|
|
436
|
+
* Each `.ts`/`.mjs`/`.js` file's default export should be a `ToolDefinition`
|
|
437
|
+
* or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
|
|
438
|
+
* `tools/_example.ts` documents the shape without registering.
|
|
439
|
+
*/
|
|
440
|
+
const log$12 = logger.child({ module: "local-tools-extension" });
|
|
441
|
+
const TOOLS_DIRNAME = "tools";
|
|
442
|
+
const fileState = /* @__PURE__ */ new Map();
|
|
443
|
+
let pendingLocalToolsUpdate = null;
|
|
444
|
+
function consumePendingLocalToolsUpdate() {
|
|
445
|
+
const update = pendingLocalToolsUpdate;
|
|
446
|
+
pendingLocalToolsUpdate = null;
|
|
447
|
+
return update;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Non-consuming peek used by the agent loop's `shouldStopAfterTurn` hook to
|
|
451
|
+
* decide whether to end the current `prompt()` after the in-flight turn so a
|
|
452
|
+
* fresh tool snapshot can be taken. The drain still happens in postPrompt via
|
|
453
|
+
* `consumePendingLocalToolsUpdate`.
|
|
454
|
+
*/
|
|
455
|
+
function hasPendingLocalToolsUpdate() {
|
|
456
|
+
return pendingLocalToolsUpdate !== null;
|
|
457
|
+
}
|
|
458
|
+
function formatLocalToolsUpdateMessage(summary) {
|
|
459
|
+
const lines = ["[system] Your local tools/ inventory changed during the previous turn. Your tool list is now updated; act on the new set rather than what was visible before."];
|
|
460
|
+
if (summary.added.length > 0) lines.push(`Newly loaded tool files: ${summary.added.join(", ")}`);
|
|
461
|
+
if (summary.refreshed.length > 0) lines.push(`Refreshed tool files: ${summary.refreshed.join(", ")}`);
|
|
462
|
+
if (summary.removed.length > 0) lines.push(`Removed tool files (and their tools): ${summary.removed.join(", ")}`);
|
|
463
|
+
if (summary.errors.length > 0) {
|
|
464
|
+
lines.push("Errors:");
|
|
465
|
+
for (const e of summary.errors) lines.push(` - ${e.file}: ${e.message}`);
|
|
466
|
+
}
|
|
467
|
+
if (summary.added.length > 0) lines.push(CAPABILITY_SOUL_NUDGE);
|
|
468
|
+
lines.push("Continue from where you left off, using the current tool list. Do not re-do work that already succeeded last turn.");
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
|
471
|
+
function isToolDefinition(x) {
|
|
472
|
+
return !!x && typeof x === "object" && typeof x.name === "string" && typeof x.execute === "function";
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Pi's system-prompt builder filters its visible-tool list to entries with a
|
|
476
|
+
* non-empty `promptSnippet` (system-prompt.js:49) — a tool with only a
|
|
477
|
+
* `description` won't appear in the system prompt's "available tools"
|
|
478
|
+
* section, which biases the LLM against using it. If the local tool author
|
|
479
|
+
* didn't set a snippet, default to the tool's description so the tool stays
|
|
480
|
+
* visible.
|
|
481
|
+
*/
|
|
482
|
+
function withDefaultPromptSnippet(tool) {
|
|
483
|
+
if (typeof tool.promptSnippet === "string" && tool.promptSnippet.trim()) return tool;
|
|
484
|
+
const fallback = tool.description?.trim();
|
|
485
|
+
if (!fallback) return tool;
|
|
486
|
+
return {
|
|
487
|
+
...tool,
|
|
488
|
+
promptSnippet: fallback
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async function listToolFiles(dir) {
|
|
492
|
+
let entries;
|
|
493
|
+
try {
|
|
494
|
+
entries = await readdir(dir);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
if (err?.code === "ENOENT") return [];
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
const out = [];
|
|
500
|
+
for (const file of entries) {
|
|
501
|
+
if (!file.endsWith(".ts") && !file.endsWith(".mjs") && !file.endsWith(".js")) continue;
|
|
502
|
+
if (file.startsWith("_") || file.startsWith(".")) continue;
|
|
503
|
+
const full = join(dir, file);
|
|
504
|
+
try {
|
|
505
|
+
const s = await stat(full);
|
|
506
|
+
if (!s.isFile()) continue;
|
|
507
|
+
out.push({
|
|
508
|
+
file,
|
|
509
|
+
mtimeMs: s.mtimeMs
|
|
510
|
+
});
|
|
511
|
+
} catch {}
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
async function importToolFile({ dir, file, mtimeMs }) {
|
|
516
|
+
const mod = await import(`${pathToFileURL(join(dir, file)).href}?v=${mtimeMs}`);
|
|
517
|
+
const exported = mod.default ?? mod.tool ?? mod.tools;
|
|
518
|
+
const tools = [];
|
|
519
|
+
if (Array.isArray(exported)) {
|
|
520
|
+
for (const t of exported) if (isToolDefinition(t)) tools.push(t);
|
|
521
|
+
} else if (isToolDefinition(exported)) tools.push(exported);
|
|
522
|
+
if (tools.length === 0) throw new Error("no valid ToolDefinition export");
|
|
523
|
+
return tools;
|
|
524
|
+
}
|
|
525
|
+
async function reconcileLocalTools({ pi, dir }) {
|
|
526
|
+
const summary = {
|
|
527
|
+
added: [],
|
|
528
|
+
removed: [],
|
|
529
|
+
refreshed: [],
|
|
530
|
+
errors: [],
|
|
531
|
+
totalTools: 0
|
|
532
|
+
};
|
|
533
|
+
const current = await listToolFiles(dir);
|
|
534
|
+
const currentByName = new Map(current.map((e) => [e.file, e.mtimeMs]));
|
|
535
|
+
for (const file of [...fileState.keys()]) if (!currentByName.has(file)) {
|
|
536
|
+
fileState.delete(file);
|
|
537
|
+
summary.removed.push(file);
|
|
538
|
+
}
|
|
539
|
+
for (const { file, mtimeMs } of current) {
|
|
540
|
+
const existing = fileState.get(file);
|
|
541
|
+
let tools;
|
|
542
|
+
let action;
|
|
543
|
+
if (existing && existing.mtimeMs === mtimeMs) {
|
|
544
|
+
tools = existing.tools;
|
|
545
|
+
action = "reused";
|
|
546
|
+
} else {
|
|
547
|
+
try {
|
|
548
|
+
tools = await importToolFile({
|
|
549
|
+
dir,
|
|
550
|
+
file,
|
|
551
|
+
mtimeMs
|
|
552
|
+
});
|
|
553
|
+
} catch (err) {
|
|
554
|
+
summary.errors.push({
|
|
555
|
+
file,
|
|
556
|
+
message: err instanceof Error ? err.message : String(err)
|
|
557
|
+
});
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
fileState.set(file, {
|
|
561
|
+
mtimeMs,
|
|
562
|
+
tools
|
|
563
|
+
});
|
|
564
|
+
action = existing ? "refreshed" : "added";
|
|
565
|
+
}
|
|
566
|
+
for (const tool of tools) {
|
|
567
|
+
pi.registerTool(withDefaultPromptSnippet(tool));
|
|
568
|
+
summary.totalTools++;
|
|
569
|
+
}
|
|
570
|
+
if (action === "added") summary.added.push(file);
|
|
571
|
+
else if (action === "refreshed") summary.refreshed.push(file);
|
|
572
|
+
}
|
|
573
|
+
return summary;
|
|
574
|
+
}
|
|
575
|
+
function summaryHasChanges$1(s) {
|
|
576
|
+
return s.added.length > 0 || s.removed.length > 0 || s.refreshed.length > 0 || s.errors.length > 0;
|
|
577
|
+
}
|
|
578
|
+
async function reconcileAndQueue({ pi, dir, reason }) {
|
|
579
|
+
const summary = await reconcileLocalTools({
|
|
580
|
+
pi,
|
|
581
|
+
dir
|
|
582
|
+
});
|
|
583
|
+
if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
|
|
584
|
+
log$12.info({
|
|
585
|
+
event: "local_tools_reconcile",
|
|
586
|
+
reason,
|
|
587
|
+
total_tools: summary.totalTools,
|
|
588
|
+
added: summary.added,
|
|
589
|
+
removed: summary.removed,
|
|
590
|
+
refreshed: summary.refreshed,
|
|
591
|
+
errors: summary.errors,
|
|
592
|
+
queued_continuation: reason !== "session_start" && summaryHasChanges$1(summary)
|
|
593
|
+
}, "local tools reconcile complete");
|
|
594
|
+
return summary;
|
|
595
|
+
}
|
|
596
|
+
const localToolsExtension = (pi) => {
|
|
597
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
598
|
+
const dir = join(ctx.cwd, TOOLS_DIRNAME);
|
|
599
|
+
try {
|
|
600
|
+
await reconcileAndQueue({
|
|
601
|
+
pi,
|
|
602
|
+
dir,
|
|
603
|
+
reason: "session_start"
|
|
604
|
+
});
|
|
605
|
+
} catch (err) {
|
|
606
|
+
log$12.error({
|
|
607
|
+
err,
|
|
608
|
+
event: "local_tools_reconcile_failed"
|
|
609
|
+
}, "local tools reconcile failed");
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
pi.on("tool_result", async (_event, ctx) => {
|
|
613
|
+
const dir = join(ctx.cwd, TOOLS_DIRNAME);
|
|
614
|
+
let current;
|
|
615
|
+
try {
|
|
616
|
+
current = await listToolFiles(dir);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
log$12.warn({
|
|
619
|
+
err,
|
|
620
|
+
event: "local_tools_listing_failed"
|
|
621
|
+
}, "tools/ listing failed");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
let changed = current.length !== fileState.size;
|
|
625
|
+
if (!changed) for (const { file, mtimeMs } of current) {
|
|
626
|
+
const existing = fileState.get(file);
|
|
627
|
+
if (!existing || existing.mtimeMs !== mtimeMs) {
|
|
628
|
+
changed = true;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (!changed) return;
|
|
633
|
+
try {
|
|
634
|
+
await reconcileAndQueue({
|
|
635
|
+
pi,
|
|
636
|
+
dir,
|
|
637
|
+
reason: "auto_reload"
|
|
638
|
+
});
|
|
639
|
+
} catch (err) {
|
|
640
|
+
log$12.error({
|
|
641
|
+
err,
|
|
642
|
+
event: "local_tools_auto_reload_failed"
|
|
643
|
+
}, "auto-reload after tools/ change failed");
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
};
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/extensions/mcp/stderr-buffer.ts
|
|
649
|
+
function createStderrBuffer({ maxBytes }) {
|
|
650
|
+
const chunks = [];
|
|
651
|
+
let bytes = 0;
|
|
652
|
+
return {
|
|
653
|
+
attach(stream) {
|
|
654
|
+
stream.on("data", (chunk) => {
|
|
655
|
+
chunks.push(chunk);
|
|
656
|
+
bytes += chunk.length;
|
|
657
|
+
while (bytes > maxBytes && chunks.length > 1) {
|
|
658
|
+
const dropped = chunks.shift();
|
|
659
|
+
bytes -= dropped.length;
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
},
|
|
663
|
+
read() {
|
|
664
|
+
if (chunks.length === 0) return "";
|
|
665
|
+
const full = Buffer.concat(chunks).toString("utf8");
|
|
666
|
+
return full.length > maxBytes ? full.slice(full.length - maxBytes) : full;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/extensions/mcp/connect-client.ts
|
|
672
|
+
/**
|
|
673
|
+
* Spawns an MCP server's transport and races the initialize handshake
|
|
674
|
+
* against a bounded timeout. The motivating case is OAuth-pending
|
|
675
|
+
* bridges like `mcp-remote`: they spawn fine, print their auth URL to
|
|
676
|
+
* stderr, then block on JSON-RPC `initialize` until the user completes
|
|
677
|
+
* OAuth. Without a bounded wait, `client.connect(transport)` hangs
|
|
678
|
+
* forever and the reconcile loop stalls.
|
|
679
|
+
*
|
|
680
|
+
* On timeout we return `status: 'timeout'` with the captured stderr.
|
|
681
|
+
* The transport stays alive — caller holds the client so the bridge's
|
|
682
|
+
* callback server keeps listening and a later reconcile re-probes.
|
|
683
|
+
*/
|
|
684
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 5e3;
|
|
685
|
+
const STDERR_BUFFER_BYTES = 4096;
|
|
686
|
+
/**
|
|
687
|
+
* undici's fetch throws `TypeError: fetch failed` with the actual
|
|
688
|
+
* network error hung off `.cause` (e.g. `getaddrinfo ENOTFOUND ...`,
|
|
689
|
+
* `ECONNREFUSED`, TLS errors). Surfacing only `err.message` makes
|
|
690
|
+
* every network failure read as a bare "fetch failed", which is
|
|
691
|
+
* indistinguishable from any other transport problem. Walk the cause
|
|
692
|
+
* chain so the agent sees the real underlying error.
|
|
693
|
+
*/
|
|
694
|
+
function formatError(err) {
|
|
695
|
+
if (!(err instanceof Error)) return String(err);
|
|
696
|
+
const parts = [err.message];
|
|
697
|
+
let cursor = err.cause;
|
|
698
|
+
while (cursor instanceof Error) {
|
|
699
|
+
const code = cursor.code;
|
|
700
|
+
parts.push(typeof code === "string" ? `${cursor.message} (${code})` : cursor.message);
|
|
701
|
+
cursor = cursor.cause;
|
|
702
|
+
}
|
|
703
|
+
return parts.join(": ");
|
|
704
|
+
}
|
|
705
|
+
async function connectHttp(_id, config, client) {
|
|
706
|
+
const transport = new StreamableHTTPClientTransport(new URL(config.url), { ...config.headers !== null ? { requestInit: { headers: config.headers } } : {} });
|
|
707
|
+
try {
|
|
708
|
+
await client.connect(transport);
|
|
709
|
+
return {
|
|
710
|
+
status: "connected",
|
|
711
|
+
client,
|
|
712
|
+
stderr: null
|
|
713
|
+
};
|
|
714
|
+
} catch (err) {
|
|
715
|
+
if (err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401) return {
|
|
716
|
+
status: "pending_auth",
|
|
717
|
+
client,
|
|
718
|
+
stderr: "",
|
|
719
|
+
stderrBuffer: null,
|
|
720
|
+
cliHint: `platform auth mcp ${config.url}`
|
|
721
|
+
};
|
|
722
|
+
return {
|
|
723
|
+
status: "failed",
|
|
724
|
+
error: formatError(err),
|
|
725
|
+
stderr: ""
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async function connectClient(id, config, opts = {}) {
|
|
730
|
+
const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
731
|
+
const client = new Client({
|
|
732
|
+
name: "skydive-harness",
|
|
733
|
+
version: "0.1.0"
|
|
734
|
+
}, { capabilities: {} });
|
|
735
|
+
if (config.transport === "http") return connectHttp(id, config, client);
|
|
736
|
+
const params = {
|
|
737
|
+
command: config.command,
|
|
738
|
+
args: config.args,
|
|
739
|
+
stderr: "pipe"
|
|
740
|
+
};
|
|
741
|
+
if (config.env !== null) params.env = config.env;
|
|
742
|
+
if (config.cwd !== null) params.cwd = config.cwd;
|
|
743
|
+
const transport = new StdioClientTransport(params);
|
|
744
|
+
const stderrBuffer = createStderrBuffer({ maxBytes: STDERR_BUFFER_BYTES });
|
|
745
|
+
if (transport.stderr) stderrBuffer.attach(transport.stderr);
|
|
746
|
+
let exited = null;
|
|
747
|
+
const exitPromise = new Promise((resolve) => {
|
|
748
|
+
transport.onclose = () => {
|
|
749
|
+
exited = { code: null };
|
|
750
|
+
resolve();
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
const connectPromise = client.connect(transport);
|
|
754
|
+
const TIMEOUT_SENTINEL = Symbol("timeout");
|
|
755
|
+
const result = await Promise.race([
|
|
756
|
+
connectPromise.then(() => "connected").catch((err) => err),
|
|
757
|
+
exitPromise.then(() => "exited"),
|
|
758
|
+
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs))
|
|
759
|
+
]);
|
|
760
|
+
if (result === "connected") return {
|
|
761
|
+
status: "connected",
|
|
762
|
+
client,
|
|
763
|
+
stderr: stderrBuffer
|
|
764
|
+
};
|
|
765
|
+
if (result === "exited" || exited !== null) return {
|
|
766
|
+
status: "failed",
|
|
767
|
+
error: `server "${id}" exited before completing initialize`,
|
|
768
|
+
stderr: stderrBuffer.read()
|
|
769
|
+
};
|
|
770
|
+
if (result === TIMEOUT_SENTINEL) return {
|
|
771
|
+
status: "timeout",
|
|
772
|
+
client,
|
|
773
|
+
stderr: stderrBuffer.read(),
|
|
774
|
+
stderrBuffer
|
|
775
|
+
};
|
|
776
|
+
try {
|
|
777
|
+
await client.close();
|
|
778
|
+
} catch {}
|
|
779
|
+
return {
|
|
780
|
+
status: "failed",
|
|
781
|
+
error: formatError(result),
|
|
782
|
+
stderr: stderrBuffer.read()
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/extensions/mcp/mcp-config.ts
|
|
787
|
+
/**
|
|
788
|
+
* mcp.config.json schema + loader. Split out from the extension so it has
|
|
789
|
+
* a small, focused unit-test surface (typebox validation, error formatting).
|
|
790
|
+
*/
|
|
791
|
+
const MCP_CONFIG_FILENAME = "mcp.config.json";
|
|
792
|
+
const StdioServerSchema = Type.Object({
|
|
793
|
+
transport: Type.Literal("stdio"),
|
|
794
|
+
command: Type.String(),
|
|
795
|
+
args: Type.Array(Type.String()),
|
|
796
|
+
env: Type.Optional(Type.Union([Type.Record(Type.String(), Type.String()), Type.Null()])),
|
|
797
|
+
cwd: Type.Optional(Type.Union([Type.String(), Type.Null()]))
|
|
798
|
+
});
|
|
799
|
+
const HttpServerSchema = Type.Object({
|
|
800
|
+
transport: Type.Literal("http"),
|
|
801
|
+
url: Type.String(),
|
|
802
|
+
headers: Type.Optional(Type.Union([Type.Record(Type.String(), Type.String()), Type.Null()]))
|
|
803
|
+
});
|
|
804
|
+
const ServerSchema = Type.Union([StdioServerSchema, HttpServerSchema]);
|
|
805
|
+
const McpConfigSchema = Type.Object({ servers: Type.Record(Type.String(), ServerSchema) });
|
|
806
|
+
const EMPTY_CONFIG = { servers: {} };
|
|
807
|
+
async function loadMcpConfig(path) {
|
|
808
|
+
let text;
|
|
809
|
+
try {
|
|
810
|
+
text = await readFile(path, "utf8");
|
|
811
|
+
} catch (err) {
|
|
812
|
+
if (err?.code === "ENOENT") return EMPTY_CONFIG;
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
let json;
|
|
816
|
+
try {
|
|
817
|
+
json = JSON.parse(text);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
throw new Error(`Invalid JSON in MCP config at ${path}: ${err.message}`, { cause: err });
|
|
820
|
+
}
|
|
821
|
+
if (!Check(McpConfigSchema, json)) {
|
|
822
|
+
const details = Errors(McpConfigSchema, json).map((e) => ` ${e.instancePath || "/"}: ${e.message}`).join("\n");
|
|
823
|
+
throw new Error(`Invalid MCP config at ${path}:\n${details}`);
|
|
824
|
+
}
|
|
825
|
+
const servers = {};
|
|
826
|
+
for (const [id, server] of Object.entries(json.servers)) if (server.transport === "stdio") servers[id] = {
|
|
827
|
+
...server,
|
|
828
|
+
env: server.env ?? null,
|
|
829
|
+
cwd: server.cwd ?? null
|
|
830
|
+
};
|
|
831
|
+
else servers[id] = {
|
|
832
|
+
...server,
|
|
833
|
+
headers: server.headers ?? null
|
|
834
|
+
};
|
|
835
|
+
return { servers };
|
|
836
|
+
}
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/extensions/mcp/index.ts
|
|
839
|
+
/**
|
|
840
|
+
* MCP adapter as a pi extension.
|
|
841
|
+
*
|
|
842
|
+
* Owns the lifecycle of MCP server clients and registers their tools with pi
|
|
843
|
+
* via `pi.registerTool`. The harness process is long-lived and runs one
|
|
844
|
+
* McpExtension instance — state (open clients, registered tool names,
|
|
845
|
+
* mtime baseline, pending update) is per-instance so tests can construct
|
|
846
|
+
* fresh ones instead of poking module-scoped vars.
|
|
847
|
+
*
|
|
848
|
+
* **Mid-turn tool-list updates do not work in pi-agent-core.** The agent
|
|
849
|
+
* loop snapshots `state.tools` at the top of each `session.prompt(...)`
|
|
850
|
+
* and reuses that snapshot for every LLM iteration in the turn. New
|
|
851
|
+
* registrations only land in the *next* prompt's snapshot. The harness
|
|
852
|
+
* orchestrates around this via `runToolUpdateLoop` (postPrompt):
|
|
853
|
+
*
|
|
854
|
+
* 1. `session.reload()` rebuilds pi's tool registry; our session_start
|
|
855
|
+
* hook re-reconciles against an empty registry, so removed servers
|
|
856
|
+
* drop out (pi has no `unregisterTool`).
|
|
857
|
+
* 2. `consumePendingMcpUpdate()` + `formatMcpUpdateMessage()` produce
|
|
858
|
+
* a synthetic system note injected via `sendCustomMessage(...,
|
|
859
|
+
* { triggerTurn: true })` — fresh `state.tools` snapshot for the
|
|
860
|
+
* next prompt.
|
|
861
|
+
*
|
|
862
|
+
* Clients are keyed by JSON-stringified config and reused across
|
|
863
|
+
* reloads — only changed configs reconnect.
|
|
864
|
+
*/
|
|
865
|
+
const log$11 = logger.child({ module: "mcp-extension" });
|
|
866
|
+
async function closeConnected(connected) {
|
|
867
|
+
try {
|
|
868
|
+
await connected.client.close();
|
|
869
|
+
} catch (err) {
|
|
870
|
+
logger.warn({
|
|
871
|
+
err,
|
|
872
|
+
event: "mcp_client_close_failed"
|
|
873
|
+
}, "failed to close MCP client; transport may leak");
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function readConfigMtimeMs(configPath) {
|
|
877
|
+
try {
|
|
878
|
+
return (await stat(configPath)).mtimeMs;
|
|
879
|
+
} catch (err) {
|
|
880
|
+
if (err?.code === "ENOENT") return 0;
|
|
881
|
+
throw err;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function makeToolName(serverId, toolName) {
|
|
885
|
+
return `mcp_${serverId}_${toolName}`;
|
|
886
|
+
}
|
|
887
|
+
function toMcpArguments(params) {
|
|
888
|
+
const out = {};
|
|
889
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return out;
|
|
890
|
+
for (const [key, value] of Object.entries(params)) out[key] = value;
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
function isCallToolResult(result) {
|
|
894
|
+
return "content" in result;
|
|
895
|
+
}
|
|
896
|
+
function mcpContentPartToText(part) {
|
|
897
|
+
switch (part.type) {
|
|
898
|
+
case "text": return part.text;
|
|
899
|
+
case "audio": return `[audio: ${part.mimeType}]`;
|
|
900
|
+
case "resource": {
|
|
901
|
+
const { resource } = part;
|
|
902
|
+
if ("text" in resource) return resource.text;
|
|
903
|
+
return `[resource: ${resource.uri}]`;
|
|
904
|
+
}
|
|
905
|
+
case "resource_link": return `[resource link: ${part.name} (${part.uri})]`;
|
|
906
|
+
default: return `[unsupported MCP content type: ${JSON.stringify(part)}]`;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
function mcpResultToPiContent(result) {
|
|
910
|
+
const content = [];
|
|
911
|
+
const textParts = [];
|
|
912
|
+
if (result.isError) textParts.push("MCP tool reported an error.");
|
|
913
|
+
for (const part of result.content) {
|
|
914
|
+
if (part.type === "image") {
|
|
915
|
+
content.push({
|
|
916
|
+
type: "image",
|
|
917
|
+
data: part.data,
|
|
918
|
+
mimeType: part.mimeType
|
|
919
|
+
});
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const text = mcpContentPartToText(part);
|
|
923
|
+
if (text) textParts.push(text);
|
|
924
|
+
}
|
|
925
|
+
if (result.structuredContent) textParts.push(`Structured content:\n${JSON.stringify(result.structuredContent, null, 2)}`);
|
|
926
|
+
if (textParts.length > 0) content.unshift({
|
|
927
|
+
type: "text",
|
|
928
|
+
text: textParts.join("\n")
|
|
929
|
+
});
|
|
930
|
+
return content.length > 0 ? content : [{
|
|
931
|
+
type: "text",
|
|
932
|
+
text: "MCP tool returned no content."
|
|
933
|
+
}];
|
|
934
|
+
}
|
|
935
|
+
var McpExtension = class {
|
|
936
|
+
mcpClients = /* @__PURE__ */ new Map();
|
|
937
|
+
registeredMcpToolNames = /* @__PURE__ */ new Set();
|
|
938
|
+
lastConfigMtimeMs = 0;
|
|
939
|
+
pendingMcpUpdate = null;
|
|
940
|
+
/** Non-consuming peek used by `shouldStopAfterTurn`. */
|
|
941
|
+
hasPendingUpdate() {
|
|
942
|
+
return this.pendingMcpUpdate !== null;
|
|
943
|
+
}
|
|
944
|
+
/** Read + clear the queued mid-turn update; null if nothing pending. */
|
|
945
|
+
consumePendingUpdate() {
|
|
946
|
+
const update = this.pendingMcpUpdate;
|
|
947
|
+
this.pendingMcpUpdate = null;
|
|
948
|
+
return update;
|
|
949
|
+
}
|
|
950
|
+
registerMcpTool({ pi, serverId, client, tool }) {
|
|
951
|
+
const name = makeToolName(serverId, tool.name);
|
|
952
|
+
const parameters = Type.Unsafe(tool.inputSchema);
|
|
953
|
+
const description = tool.description?.trim() ?? "";
|
|
954
|
+
const promptSnippet = description.length > 0 ? description : `MCP tool from server "${serverId}".`;
|
|
955
|
+
pi.registerTool({
|
|
956
|
+
name,
|
|
957
|
+
label: `MCP: ${serverId}/${tool.name}`,
|
|
958
|
+
description,
|
|
959
|
+
promptSnippet,
|
|
960
|
+
parameters,
|
|
961
|
+
async execute(_toolCallId, params) {
|
|
962
|
+
try {
|
|
963
|
+
const result = await client.callTool({
|
|
964
|
+
name: tool.name,
|
|
965
|
+
arguments: toMcpArguments(params)
|
|
966
|
+
}, CallToolResultSchema);
|
|
967
|
+
if (!isCallToolResult(result)) throw new Error("MCP tool returned an unsupported compatibility result");
|
|
968
|
+
return {
|
|
969
|
+
content: mcpResultToPiContent(result),
|
|
970
|
+
details: {
|
|
971
|
+
result,
|
|
972
|
+
error: null
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
} catch (err) {
|
|
976
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
977
|
+
return {
|
|
978
|
+
content: [{
|
|
979
|
+
type: "text",
|
|
980
|
+
text: `MCP tool ${serverId}/${tool.name} failed: ${message}`
|
|
981
|
+
}],
|
|
982
|
+
details: {
|
|
983
|
+
result: null,
|
|
984
|
+
error: message
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
this.registeredMcpToolNames.add(name);
|
|
991
|
+
}
|
|
992
|
+
async reconcile({ pi, configPath, connectTimeoutMs }) {
|
|
993
|
+
let config;
|
|
994
|
+
try {
|
|
995
|
+
config = await loadMcpConfig(configPath);
|
|
996
|
+
} catch (err) {
|
|
997
|
+
throw new Error(`Failed to load MCP config: ${err instanceof Error ? err.message : String(err)}`);
|
|
998
|
+
}
|
|
999
|
+
const summary = {
|
|
1000
|
+
added: [],
|
|
1001
|
+
removed: [],
|
|
1002
|
+
refreshed: [],
|
|
1003
|
+
errors: [],
|
|
1004
|
+
totalTools: 0,
|
|
1005
|
+
servers: {}
|
|
1006
|
+
};
|
|
1007
|
+
const desiredIds = new Set(Object.keys(config.servers));
|
|
1008
|
+
this.registeredMcpToolNames.clear();
|
|
1009
|
+
for (const [id, existing] of [...this.mcpClients.entries()]) if (!desiredIds.has(id)) {
|
|
1010
|
+
await closeConnected(existing);
|
|
1011
|
+
this.mcpClients.delete(id);
|
|
1012
|
+
summary.removed.push(id);
|
|
1013
|
+
}
|
|
1014
|
+
const outcomes = await Promise.all(Object.entries(config.servers).map(([id, serverConfig]) => this.reconcileServer({
|
|
1015
|
+
id,
|
|
1016
|
+
serverConfig,
|
|
1017
|
+
existing: this.mcpClients.get(id),
|
|
1018
|
+
connectTimeoutMs
|
|
1019
|
+
})));
|
|
1020
|
+
for (const outcome of outcomes) {
|
|
1021
|
+
const { id } = outcome;
|
|
1022
|
+
if (outcome.store) this.mcpClients.set(id, outcome.store);
|
|
1023
|
+
else this.mcpClients.delete(id);
|
|
1024
|
+
summary.servers[id] = outcome.serverStatus;
|
|
1025
|
+
if (outcome.error) summary.errors.push(outcome.error);
|
|
1026
|
+
if (outcome.change === "added") summary.added.push(id);
|
|
1027
|
+
else if (outcome.change === "refreshed") summary.refreshed.push(id);
|
|
1028
|
+
if (outcome.tools) for (const tool of outcome.tools.list) {
|
|
1029
|
+
this.registerMcpTool({
|
|
1030
|
+
pi,
|
|
1031
|
+
serverId: id,
|
|
1032
|
+
client: outcome.tools.client,
|
|
1033
|
+
tool
|
|
1034
|
+
});
|
|
1035
|
+
summary.totalTools++;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return summary;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Probe one server end-to-end (connect → retry pending → listTools) and
|
|
1042
|
+
* return what should be applied to shared state. Touches only its own
|
|
1043
|
+
* client (closing a stale one before reconnecting), never the shared
|
|
1044
|
+
* map/summary, so callers can run these concurrently.
|
|
1045
|
+
*/
|
|
1046
|
+
async reconcileServer({ id, serverConfig, existing, connectTimeoutMs }) {
|
|
1047
|
+
const configKey = JSON.stringify(serverConfig);
|
|
1048
|
+
let action;
|
|
1049
|
+
let connected;
|
|
1050
|
+
if (existing && existing.configKey === configKey) {
|
|
1051
|
+
connected = existing;
|
|
1052
|
+
action = "reused";
|
|
1053
|
+
} else {
|
|
1054
|
+
if (existing) await closeConnected(existing);
|
|
1055
|
+
const result = await connectClient(id, serverConfig, { connectTimeoutMs });
|
|
1056
|
+
const change = existing ? "refreshed" : "added";
|
|
1057
|
+
if (result.status === "failed") return {
|
|
1058
|
+
id,
|
|
1059
|
+
store: null,
|
|
1060
|
+
serverStatus: {
|
|
1061
|
+
status: "failed",
|
|
1062
|
+
error: result.error,
|
|
1063
|
+
stderr: result.stderr
|
|
1064
|
+
},
|
|
1065
|
+
change,
|
|
1066
|
+
error: {
|
|
1067
|
+
serverId: id,
|
|
1068
|
+
message: result.error
|
|
1069
|
+
},
|
|
1070
|
+
tools: null
|
|
1071
|
+
};
|
|
1072
|
+
if (result.status === "pending_auth") return {
|
|
1073
|
+
id,
|
|
1074
|
+
store: {
|
|
1075
|
+
client: result.client,
|
|
1076
|
+
configKey,
|
|
1077
|
+
status: "pending_auth",
|
|
1078
|
+
stderrBuffer: result.stderrBuffer,
|
|
1079
|
+
cliHint: result.cliHint
|
|
1080
|
+
},
|
|
1081
|
+
serverStatus: {
|
|
1082
|
+
status: "pending_auth",
|
|
1083
|
+
stderr: result.stderr,
|
|
1084
|
+
cliHint: result.cliHint
|
|
1085
|
+
},
|
|
1086
|
+
change,
|
|
1087
|
+
error: null,
|
|
1088
|
+
tools: null
|
|
1089
|
+
};
|
|
1090
|
+
if (result.status === "timeout") return {
|
|
1091
|
+
id,
|
|
1092
|
+
store: {
|
|
1093
|
+
client: result.client,
|
|
1094
|
+
configKey,
|
|
1095
|
+
status: "timeout",
|
|
1096
|
+
stderrBuffer: result.stderrBuffer,
|
|
1097
|
+
cliHint: null
|
|
1098
|
+
},
|
|
1099
|
+
serverStatus: {
|
|
1100
|
+
status: "timeout",
|
|
1101
|
+
stderr: result.stderr
|
|
1102
|
+
},
|
|
1103
|
+
change,
|
|
1104
|
+
error: null,
|
|
1105
|
+
tools: null
|
|
1106
|
+
};
|
|
1107
|
+
connected = {
|
|
1108
|
+
client: result.client,
|
|
1109
|
+
configKey,
|
|
1110
|
+
status: "connected",
|
|
1111
|
+
stderrBuffer: result.stderr,
|
|
1112
|
+
cliHint: null
|
|
1113
|
+
};
|
|
1114
|
+
action = existing ? "refreshed" : "added";
|
|
1115
|
+
}
|
|
1116
|
+
if (connected.status === "pending_auth") {
|
|
1117
|
+
await closeConnected(connected);
|
|
1118
|
+
const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
|
|
1119
|
+
if (retry.status === "pending_auth") return {
|
|
1120
|
+
id,
|
|
1121
|
+
store: {
|
|
1122
|
+
client: retry.client,
|
|
1123
|
+
configKey,
|
|
1124
|
+
status: "pending_auth",
|
|
1125
|
+
stderrBuffer: null,
|
|
1126
|
+
cliHint: retry.cliHint
|
|
1127
|
+
},
|
|
1128
|
+
serverStatus: {
|
|
1129
|
+
status: "pending_auth",
|
|
1130
|
+
stderr: "",
|
|
1131
|
+
cliHint: retry.cliHint
|
|
1132
|
+
},
|
|
1133
|
+
change: null,
|
|
1134
|
+
error: null,
|
|
1135
|
+
tools: null
|
|
1136
|
+
};
|
|
1137
|
+
if (retry.status === "timeout") return {
|
|
1138
|
+
id,
|
|
1139
|
+
store: {
|
|
1140
|
+
client: retry.client,
|
|
1141
|
+
configKey,
|
|
1142
|
+
status: "timeout",
|
|
1143
|
+
stderrBuffer: retry.stderrBuffer,
|
|
1144
|
+
cliHint: null
|
|
1145
|
+
},
|
|
1146
|
+
serverStatus: {
|
|
1147
|
+
status: "timeout",
|
|
1148
|
+
stderr: retry.stderr
|
|
1149
|
+
},
|
|
1150
|
+
change: null,
|
|
1151
|
+
error: null,
|
|
1152
|
+
tools: null
|
|
1153
|
+
};
|
|
1154
|
+
if (retry.status === "failed") return {
|
|
1155
|
+
id,
|
|
1156
|
+
store: null,
|
|
1157
|
+
serverStatus: {
|
|
1158
|
+
status: "failed",
|
|
1159
|
+
error: retry.error,
|
|
1160
|
+
stderr: retry.stderr
|
|
1161
|
+
},
|
|
1162
|
+
change: null,
|
|
1163
|
+
error: null,
|
|
1164
|
+
tools: null
|
|
1165
|
+
};
|
|
1166
|
+
connected = {
|
|
1167
|
+
client: retry.client,
|
|
1168
|
+
configKey,
|
|
1169
|
+
status: "connected",
|
|
1170
|
+
stderrBuffer: retry.stderr,
|
|
1171
|
+
cliHint: null
|
|
1172
|
+
};
|
|
1173
|
+
action = "refreshed";
|
|
1174
|
+
} else if (connected.status === "timeout") {
|
|
1175
|
+
const stderr = connected.stderrBuffer?.read() ?? "";
|
|
1176
|
+
return {
|
|
1177
|
+
id,
|
|
1178
|
+
store: connected,
|
|
1179
|
+
serverStatus: {
|
|
1180
|
+
status: "timeout",
|
|
1181
|
+
stderr
|
|
1182
|
+
},
|
|
1183
|
+
change: null,
|
|
1184
|
+
error: null,
|
|
1185
|
+
tools: null
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
let mcpTools;
|
|
1189
|
+
try {
|
|
1190
|
+
mcpTools = (await connected.client.listTools()).tools;
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1193
|
+
const stderr = connected.stderrBuffer?.read() ?? "";
|
|
1194
|
+
return {
|
|
1195
|
+
id,
|
|
1196
|
+
store: connected,
|
|
1197
|
+
serverStatus: {
|
|
1198
|
+
status: "failed",
|
|
1199
|
+
error: message,
|
|
1200
|
+
stderr
|
|
1201
|
+
},
|
|
1202
|
+
change: null,
|
|
1203
|
+
error: {
|
|
1204
|
+
serverId: id,
|
|
1205
|
+
message
|
|
1206
|
+
},
|
|
1207
|
+
tools: null
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
id,
|
|
1212
|
+
store: connected,
|
|
1213
|
+
serverStatus: { status: "connected" },
|
|
1214
|
+
change: action === "reused" ? null : action,
|
|
1215
|
+
error: null,
|
|
1216
|
+
tools: {
|
|
1217
|
+
client: connected.client,
|
|
1218
|
+
list: mcpTools
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
async reconcileAndRecordMtime({ pi, configPath, reason }) {
|
|
1223
|
+
const summary = await this.reconcile({
|
|
1224
|
+
pi,
|
|
1225
|
+
configPath
|
|
1226
|
+
});
|
|
1227
|
+
this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
|
|
1228
|
+
if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
|
|
1229
|
+
log$11.info({
|
|
1230
|
+
event: "mcp_reconcile",
|
|
1231
|
+
reason,
|
|
1232
|
+
total_tools: summary.totalTools,
|
|
1233
|
+
added: summary.added,
|
|
1234
|
+
removed: summary.removed,
|
|
1235
|
+
refreshed: summary.refreshed,
|
|
1236
|
+
errors: summary.errors,
|
|
1237
|
+
queued_continuation: reason !== "session_start" && summaryHasChanges(summary)
|
|
1238
|
+
}, "MCP reconcile complete");
|
|
1239
|
+
return summary;
|
|
1240
|
+
}
|
|
1241
|
+
asExtensionFactory() {
|
|
1242
|
+
return (pi) => {
|
|
1243
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1244
|
+
const configPath = join(ctx.cwd, MCP_CONFIG_FILENAME);
|
|
1245
|
+
try {
|
|
1246
|
+
await this.reconcileAndRecordMtime({
|
|
1247
|
+
pi,
|
|
1248
|
+
configPath,
|
|
1249
|
+
reason: "session_start"
|
|
1250
|
+
});
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
log$11.error({
|
|
1253
|
+
err,
|
|
1254
|
+
event: "mcp_reconcile_failed"
|
|
1255
|
+
}, "MCP reconcile failed");
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
pi.on("tool_result", async (_event, ctx) => {
|
|
1259
|
+
const configPath = join(ctx.cwd, MCP_CONFIG_FILENAME);
|
|
1260
|
+
let mtime;
|
|
1261
|
+
try {
|
|
1262
|
+
mtime = await readConfigMtimeMs(configPath);
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
log$11.warn({
|
|
1265
|
+
err,
|
|
1266
|
+
event: "mcp_mtime_check_failed"
|
|
1267
|
+
}, "mtime check on mcp.config.json failed");
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (mtime === this.lastConfigMtimeMs) return;
|
|
1271
|
+
try {
|
|
1272
|
+
await this.reconcileAndRecordMtime({
|
|
1273
|
+
pi,
|
|
1274
|
+
configPath,
|
|
1275
|
+
reason: "auto_reload"
|
|
1276
|
+
});
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
log$11.error({
|
|
1279
|
+
err,
|
|
1280
|
+
event: "mcp_auto_reload_failed"
|
|
1281
|
+
}, "auto-reload after mcp.config.json change failed");
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
pi.registerTool({
|
|
1285
|
+
name: "reload_mcp",
|
|
1286
|
+
label: "Reload MCP servers (fallback)",
|
|
1287
|
+
description: "Re-read mcp.config.json and reconcile MCP server connections. You usually do NOT need to call this — edits to mcp.config.json are detected automatically and the harness injects a follow-up turn that picks up the new tool set. Call it explicitly only when the config didn't change but the environment did: e.g. you just `pip install`'d a Python package that an existing MCP server's command needs to spawn, and you want to retry the connection without touching the file. The harness will inject a continuation turn after this call so the new tool inventory is visible.",
|
|
1288
|
+
parameters: Type.Object({}),
|
|
1289
|
+
execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
|
|
1290
|
+
const configPath = join(ctx.cwd, MCP_CONFIG_FILENAME);
|
|
1291
|
+
try {
|
|
1292
|
+
const summary = await this.reconcileAndRecordMtime({
|
|
1293
|
+
pi,
|
|
1294
|
+
configPath,
|
|
1295
|
+
reason: "tool"
|
|
1296
|
+
});
|
|
1297
|
+
return {
|
|
1298
|
+
content: [{
|
|
1299
|
+
type: "text",
|
|
1300
|
+
text: summaryText(summary)
|
|
1301
|
+
}],
|
|
1302
|
+
details: {
|
|
1303
|
+
toolCount: summary.totalTools,
|
|
1304
|
+
summary
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1309
|
+
return {
|
|
1310
|
+
content: [{
|
|
1311
|
+
type: "text",
|
|
1312
|
+
text: `reload_mcp failed: ${message}`
|
|
1313
|
+
}],
|
|
1314
|
+
details: { error: message }
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
const defaultMcp = new McpExtension();
|
|
1323
|
+
defaultMcp.reconcile.bind(defaultMcp);
|
|
1324
|
+
const consumePendingMcpUpdate = defaultMcp.consumePendingUpdate.bind(defaultMcp);
|
|
1325
|
+
const hasPendingMcpUpdate = defaultMcp.hasPendingUpdate.bind(defaultMcp);
|
|
1326
|
+
var mcp_default = defaultMcp.asExtensionFactory();
|
|
1327
|
+
function pendingAuthEntries(summary) {
|
|
1328
|
+
return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "pending_auth" ? [{
|
|
1329
|
+
id,
|
|
1330
|
+
stderr: status.stderr,
|
|
1331
|
+
cliHint: status.cliHint
|
|
1332
|
+
}] : []);
|
|
1333
|
+
}
|
|
1334
|
+
function timeoutEntries(summary) {
|
|
1335
|
+
return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "timeout" ? [{
|
|
1336
|
+
id,
|
|
1337
|
+
stderr: status.stderr
|
|
1338
|
+
}] : []);
|
|
1339
|
+
}
|
|
1340
|
+
function failedEntries(summary) {
|
|
1341
|
+
return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "failed" ? [{
|
|
1342
|
+
id,
|
|
1343
|
+
error: status.error,
|
|
1344
|
+
stderr: status.stderr
|
|
1345
|
+
}] : []);
|
|
1346
|
+
}
|
|
1347
|
+
function appendStderrBlock(lines, stderr) {
|
|
1348
|
+
if (!stderr) return;
|
|
1349
|
+
lines.push(" stderr:");
|
|
1350
|
+
lines.push(" ---");
|
|
1351
|
+
for (const line of stderr.trimEnd().split("\n")) lines.push(` ${line}`);
|
|
1352
|
+
lines.push(" ---");
|
|
1353
|
+
}
|
|
1354
|
+
function summaryText(summary) {
|
|
1355
|
+
const lines = [];
|
|
1356
|
+
lines.push(`MCP reconcile complete: ${summary.totalTools} tool(s) live.`);
|
|
1357
|
+
if (summary.added.length > 0) lines.push(` Added: ${summary.added.join(", ")}`);
|
|
1358
|
+
if (summary.refreshed.length > 0) lines.push(` Refreshed: ${summary.refreshed.join(", ")}`);
|
|
1359
|
+
if (summary.removed.length > 0) lines.push(` Removed: ${summary.removed.join(", ")}`);
|
|
1360
|
+
for (const { id, stderr, cliHint } of pendingAuthEntries(summary)) {
|
|
1361
|
+
lines.push(` WAITING ON AUTH ${id}:`);
|
|
1362
|
+
lines.push(` run \`${cliHint}\` in the shell to authenticate.`);
|
|
1363
|
+
appendStderrBlock(lines, stderr);
|
|
1364
|
+
}
|
|
1365
|
+
for (const { id, stderr } of timeoutEntries(summary)) {
|
|
1366
|
+
lines.push(` TIMED OUT ${id}:`);
|
|
1367
|
+
lines.push(` bridge spawned but did not complete initialize in time`);
|
|
1368
|
+
lines.push(` (usually mcp-remote mid-OAuth — see captured stderr).`);
|
|
1369
|
+
appendStderrBlock(lines, stderr);
|
|
1370
|
+
}
|
|
1371
|
+
for (const { id, error, stderr } of failedEntries(summary)) {
|
|
1372
|
+
lines.push(` FAILED ${id}: ${error}`);
|
|
1373
|
+
appendStderrBlock(lines, stderr);
|
|
1374
|
+
}
|
|
1375
|
+
for (const err of summary.errors) {
|
|
1376
|
+
if (summary.servers[err.serverId]?.status === "failed") continue;
|
|
1377
|
+
lines.push(` ERROR ${err.serverId}: ${err.message}`);
|
|
1378
|
+
}
|
|
1379
|
+
return lines.join("\n");
|
|
1380
|
+
}
|
|
1381
|
+
function summaryHasChanges(summary) {
|
|
1382
|
+
return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0;
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Format a queued tool-update as a synthetic system-style message for
|
|
1386
|
+
* the harness to inject as a continuation prompt. The agent reads this
|
|
1387
|
+
* on its next turn (which has a fresh tool-snapshot) and acts on the
|
|
1388
|
+
* new set.
|
|
1389
|
+
*/
|
|
1390
|
+
function formatMcpUpdateMessage(summary) {
|
|
1391
|
+
const lines = ["[system] Your MCP tool inventory changed during the previous turn. Your tool list is now updated; act on the new set rather than what was visible before."];
|
|
1392
|
+
if (summary.added.length > 0) lines.push(`Newly available servers: ${summary.added.join(", ")}`);
|
|
1393
|
+
if (summary.refreshed.length > 0) lines.push(`Refreshed servers: ${summary.refreshed.join(", ")}`);
|
|
1394
|
+
if (summary.removed.length > 0) lines.push(`Removed servers (and their tools): ${summary.removed.join(", ")}`);
|
|
1395
|
+
const pending = pendingAuthEntries(summary);
|
|
1396
|
+
if (pending.length > 0) {
|
|
1397
|
+
lines.push("");
|
|
1398
|
+
lines.push("Servers awaiting OAuth (http transport returned 401):");
|
|
1399
|
+
for (const { id, stderr, cliHint } of pending) {
|
|
1400
|
+
lines.push(` - ${id}:`);
|
|
1401
|
+
lines.push(` run \`${cliHint}\` to authenticate`);
|
|
1402
|
+
appendStderrBlock(lines, stderr);
|
|
1403
|
+
}
|
|
1404
|
+
lines.push("After authentication completes, call `reload_mcp` to pick up the now-connected tools — the harness won't reconcile on its own until you do.");
|
|
1405
|
+
}
|
|
1406
|
+
const timedOut = timeoutEntries(summary);
|
|
1407
|
+
if (timedOut.length > 0) {
|
|
1408
|
+
lines.push("");
|
|
1409
|
+
lines.push("Servers that timed out during initialize (bridge is still alive in the background; usually mcp-remote-style stdio bridges mid-OAuth):");
|
|
1410
|
+
for (const { id, stderr } of timedOut) {
|
|
1411
|
+
lines.push(` - ${id}:`);
|
|
1412
|
+
appendStderrBlock(lines, stderr);
|
|
1413
|
+
}
|
|
1414
|
+
lines.push("If the bridge prints an auth URL in its stderr, share it with the user. Then call `reload_mcp` once they've finished.");
|
|
1415
|
+
}
|
|
1416
|
+
const failed = failedEntries(summary);
|
|
1417
|
+
if (failed.length > 0) {
|
|
1418
|
+
lines.push("");
|
|
1419
|
+
lines.push("Servers that failed to start:");
|
|
1420
|
+
for (const { id, error, stderr } of failed) {
|
|
1421
|
+
lines.push(` - ${id}: ${error}`);
|
|
1422
|
+
appendStderrBlock(lines, stderr);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const unstructuredErrors = summary.errors.filter((e) => summary.servers[e.serverId]?.status !== "failed");
|
|
1426
|
+
if (unstructuredErrors.length > 0) {
|
|
1427
|
+
lines.push("Errors:");
|
|
1428
|
+
for (const err of unstructuredErrors) lines.push(` - ${err.serverId}: ${err.message}`);
|
|
1429
|
+
}
|
|
1430
|
+
if (summary.added.filter((id) => summary.servers[id]?.status === "connected").length > 0) lines.push(CAPABILITY_SOUL_NUDGE);
|
|
1431
|
+
lines.push("Continue from where you left off, using the current tool list. Do not re-do work that already succeeded last turn.");
|
|
1432
|
+
return lines.join("\n");
|
|
1433
|
+
}
|
|
1434
|
+
//#endregion
|
|
1435
|
+
//#region src/tool-update-loop.ts
|
|
1436
|
+
const MAX_TOOL_UPDATE_CONTINUATIONS = 3;
|
|
1437
|
+
function installToolUpdateAutoStop({ session, log }) {
|
|
1438
|
+
const agent = session.agent;
|
|
1439
|
+
if (typeof agent.createLoopConfig !== "function") throw new Error("installToolUpdateAutoStop: session.agent.createLoopConfig is missing — pi-agent-core internals changed; update tool-update-loop.ts.");
|
|
1440
|
+
const original = agent.createLoopConfig.bind(agent);
|
|
1441
|
+
agent.createLoopConfig = (options) => {
|
|
1442
|
+
return {
|
|
1443
|
+
...original(options),
|
|
1444
|
+
shouldStopAfterTurn: (ctx) => {
|
|
1445
|
+
if (ctx.message.stopReason === "error" || ctx.message.stopReason === "aborted") return false;
|
|
1446
|
+
const stop = hasPendingMcpUpdate() || hasPendingLocalToolsUpdate();
|
|
1447
|
+
if (stop) log.info({
|
|
1448
|
+
event: "tool_update_auto_stop_after_turn",
|
|
1449
|
+
mcp: hasPendingMcpUpdate(),
|
|
1450
|
+
local_tools: hasPendingLocalToolsUpdate()
|
|
1451
|
+
}, "ending turn so next prompt() snapshots refreshed tool list");
|
|
1452
|
+
return stop;
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
async function runToolUpdateLoop({ session, log }) {
|
|
1458
|
+
for (let i = 0; i < MAX_TOOL_UPDATE_CONTINUATIONS; i++) {
|
|
1459
|
+
const mcpUpdate = consumePendingMcpUpdate();
|
|
1460
|
+
const localToolsUpdate = consumePendingLocalToolsUpdate();
|
|
1461
|
+
if (!mcpUpdate && !localToolsUpdate) return;
|
|
1462
|
+
const parts = [];
|
|
1463
|
+
if (mcpUpdate) parts.push(formatMcpUpdateMessage(mcpUpdate));
|
|
1464
|
+
if (localToolsUpdate) parts.push(formatLocalToolsUpdateMessage(localToolsUpdate));
|
|
1465
|
+
const content = parts.join("\n\n");
|
|
1466
|
+
log.info({
|
|
1467
|
+
event: "tool_update_continuation_injected",
|
|
1468
|
+
iteration: i + 1,
|
|
1469
|
+
mcp: mcpUpdate ? {
|
|
1470
|
+
added: mcpUpdate.added,
|
|
1471
|
+
removed: mcpUpdate.removed,
|
|
1472
|
+
refreshed: mcpUpdate.refreshed
|
|
1473
|
+
} : null,
|
|
1474
|
+
mcp_servers: mcpUpdate?.servers ?? null,
|
|
1475
|
+
local_tools: localToolsUpdate ? {
|
|
1476
|
+
added: localToolsUpdate.added,
|
|
1477
|
+
removed: localToolsUpdate.removed,
|
|
1478
|
+
refreshed: localToolsUpdate.refreshed
|
|
1479
|
+
} : null,
|
|
1480
|
+
formatted_content: content
|
|
1481
|
+
}, "injecting tool-update continuation prompt");
|
|
1482
|
+
await session.reload();
|
|
1483
|
+
await session.bindExtensions({});
|
|
1484
|
+
const customType = mcpUpdate && localToolsUpdate ? "tool-update" : mcpUpdate ? "mcp-tool-update" : "local-tools-update";
|
|
1485
|
+
await session.sendCustomMessage({
|
|
1486
|
+
customType,
|
|
1487
|
+
content,
|
|
1488
|
+
display: false,
|
|
1489
|
+
details: {
|
|
1490
|
+
mcp: mcpUpdate,
|
|
1491
|
+
localTools: localToolsUpdate
|
|
1492
|
+
}
|
|
1493
|
+
}, { triggerTurn: true });
|
|
1494
|
+
}
|
|
1495
|
+
if (consumePendingMcpUpdate() || consumePendingLocalToolsUpdate()) log.warn({
|
|
1496
|
+
event: "tool_update_continuation_capped",
|
|
1497
|
+
cap: MAX_TOOL_UPDATE_CONTINUATIONS
|
|
1498
|
+
}, "reached tool-update continuation cap; further updates will land on next request");
|
|
1499
|
+
}
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/tracing.ts
|
|
1502
|
+
const SERVICE_NAME = "skydive-agent-harness";
|
|
1503
|
+
const DAEMON_TRACES_URL = "http://localhost:38994/v1/traces";
|
|
1504
|
+
let provider = null;
|
|
1505
|
+
function initTracing() {
|
|
1506
|
+
if (provider) return;
|
|
1507
|
+
propagation.setGlobalPropagator(new W3CTraceContextPropagator());
|
|
1508
|
+
provider = new NodeTracerProvider({ resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME }) });
|
|
1509
|
+
const exporter = new OTLPTraceExporter({ url: DAEMON_TRACES_URL });
|
|
1510
|
+
provider.addSpanProcessor(new BatchSpanProcessor(exporter, { scheduledDelayMillis: 1e3 }));
|
|
1511
|
+
provider.register();
|
|
1512
|
+
logger.info({
|
|
1513
|
+
event: "tracing_enabled",
|
|
1514
|
+
endpoint: DAEMON_TRACES_URL
|
|
1515
|
+
}, "OTel tracing initialized — exporting via daemon");
|
|
1516
|
+
const shutdown = async () => {
|
|
1517
|
+
await shutdownTracing();
|
|
1518
|
+
process.exit(0);
|
|
1519
|
+
};
|
|
1520
|
+
process.on("SIGTERM", shutdown);
|
|
1521
|
+
process.on("SIGINT", shutdown);
|
|
1522
|
+
}
|
|
1523
|
+
function getTracer() {
|
|
1524
|
+
return trace.getTracer(SERVICE_NAME);
|
|
1525
|
+
}
|
|
1526
|
+
function extractRemoteContext() {
|
|
1527
|
+
const traceparent = getCurrentTraceparent();
|
|
1528
|
+
if (!traceparent) return ROOT_CONTEXT;
|
|
1529
|
+
const carrier = { traceparent };
|
|
1530
|
+
return propagation.extract(ROOT_CONTEXT, carrier, {
|
|
1531
|
+
get: (c, key) => c[key],
|
|
1532
|
+
keys: (c) => Object.keys(c)
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
async function shutdownTracing() {
|
|
1536
|
+
if (provider) await provider.shutdown();
|
|
1537
|
+
}
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/harness.ts
|
|
1540
|
+
/**
|
|
1541
|
+
* Skydive composition over @skydiveai/pi-server: wires the platform
|
|
1542
|
+
* defaults (tracing to the daemon, tool-update hot-reload hooks, prewarm
|
|
1543
|
+
* paths, header passthrough for proxy routing, Skydive agent-card
|
|
1544
|
+
* branding) into the generic protocol server, and returns mountable
|
|
1545
|
+
* handlers. The agent supplies the pi session factory (their session.ts)
|
|
1546
|
+
* and owns the express app:
|
|
1547
|
+
*
|
|
1548
|
+
* const { platform, protocols } = createHarness({
|
|
1549
|
+
* cwd: process.cwd(),
|
|
1550
|
+
* createSession,
|
|
1551
|
+
* });
|
|
1552
|
+
* app.get('/health', platform.handlers.health);
|
|
1553
|
+
* app.use(platform.handlers.injectEnv);
|
|
1554
|
+
* app.use(platform.handlers.prewarm); // matches /_skydive/prewarm + legacy alias
|
|
1555
|
+
* app.use(protocols.handlers.all);
|
|
1556
|
+
*/
|
|
1557
|
+
const A2A_PATH = "/a2a";
|
|
1558
|
+
const AGENT_CARD_PATH = "/.well-known/agent-card.json";
|
|
1559
|
+
const PREWARM_PATHS = ["/_skydive/prewarm", "/_anyone/prewarm"];
|
|
1560
|
+
const PASSTHROUGH_HEADER_PREFIXES = ["x-anyone-", "x-skydive-"];
|
|
1561
|
+
function createHarness(options) {
|
|
1562
|
+
initTracing();
|
|
1563
|
+
readPlatformVersions().then((runtimeVersions) => logger.info({
|
|
1564
|
+
event: "runtime_versions",
|
|
1565
|
+
runtimeVersions
|
|
1566
|
+
}, "platform runtime versions"));
|
|
1567
|
+
const serverOptions = {
|
|
1568
|
+
...options,
|
|
1569
|
+
onSessionSetup: options.onSessionSetup ?? ((args) => {
|
|
1570
|
+
installToolUpdateAutoStop(args);
|
|
1571
|
+
installIterationCap(args);
|
|
1572
|
+
}),
|
|
1573
|
+
postPrompt: options.postPrompt ?? runToolUpdateLoop,
|
|
1574
|
+
passthroughHeaderPrefixes: options.passthroughHeaderPrefixes ?? PASSTHROUGH_HEADER_PREFIXES,
|
|
1575
|
+
prewarmPaths: options.prewarmPaths ?? PREWARM_PATHS
|
|
1576
|
+
};
|
|
1577
|
+
const webHandlers = createProtocolHandlers(serverOptions);
|
|
1578
|
+
const cardOverrides = {
|
|
1579
|
+
name: "Skydive Agent",
|
|
1580
|
+
description: "An AI coding agent powered by the Skydive platform.",
|
|
1581
|
+
...options.agentCard
|
|
1582
|
+
};
|
|
1583
|
+
const agentCardWeb = async (request) => {
|
|
1584
|
+
const url = new URL(request.url);
|
|
1585
|
+
if (request.method !== "GET" || url.pathname !== AGENT_CARD_PATH) return null;
|
|
1586
|
+
return Response.json(buildAgentCard(cardOverrides.url ?? `${url.origin}${A2A_PATH}`, cardOverrides));
|
|
1587
|
+
};
|
|
1588
|
+
const a2a = restHandler({
|
|
1589
|
+
requestHandler: new DefaultRequestHandler(buildAgentCard(cardOverrides.url ?? A2A_PATH, cardOverrides), new InMemoryTaskStore(), createAgentExecutor(serverOptions), new DefaultExecutionEventBusManager()),
|
|
1590
|
+
userBuilder: UserBuilder.noAuthentication
|
|
1591
|
+
});
|
|
1592
|
+
return {
|
|
1593
|
+
platform: { handlers: {
|
|
1594
|
+
/**
|
|
1595
|
+
* Platform health plus the agent's `healthMetadata`. Mount above
|
|
1596
|
+
* injectEnv — health must respond immediately for prewarm
|
|
1597
|
+
* stashing and readiness probes, and injectEnv can wait up to
|
|
1598
|
+
* 10s for env vars during boot.
|
|
1599
|
+
*/
|
|
1600
|
+
health: createHealthHandler({ metadata: options.healthMetadata ?? null }),
|
|
1601
|
+
/** Loads platform env (e2b envd / daemon long-poll). */
|
|
1602
|
+
injectEnv: createPlatformEnvMiddleware(),
|
|
1603
|
+
prewarm: webHandlerToMiddleware(webHandlers.prewarm)
|
|
1604
|
+
} },
|
|
1605
|
+
protocols: { handlers: {
|
|
1606
|
+
/** Express-style; mount at /a2a. */
|
|
1607
|
+
a2a,
|
|
1608
|
+
/** GET /.well-known/agent-card.json. */
|
|
1609
|
+
agentCard: webHandlerToMiddleware(agentCardWeb),
|
|
1610
|
+
/** Mirrors each vendor's API shape. */
|
|
1611
|
+
openai: { v1: {
|
|
1612
|
+
chat: { completions: webHandlerToMiddleware(webHandlers.chatCompletions) },
|
|
1613
|
+
responses: webHandlerToMiddleware(webHandlers.responses)
|
|
1614
|
+
} },
|
|
1615
|
+
anthropic: { v1: { messages: webHandlerToMiddleware(webHandlers.messages) } },
|
|
1616
|
+
/**
|
|
1617
|
+
* Everything in one mount: a2a (+ agent card) at their well-known
|
|
1618
|
+
* paths, then chat-completions / anthropic-messages / responses.
|
|
1619
|
+
* Calls next() when nothing matches.
|
|
1620
|
+
*/
|
|
1621
|
+
all: chainMiddleware([mountAt(A2A_PATH, a2a), webHandlerToMiddleware(composeHandlers([
|
|
1622
|
+
agentCardWeb,
|
|
1623
|
+
webHandlers.chatCompletions,
|
|
1624
|
+
webHandlers.messages,
|
|
1625
|
+
webHandlers.responses
|
|
1626
|
+
]))])
|
|
1627
|
+
} }
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
/** Effective bash timeout: the model's value when it gave a positive number, else the default. */
|
|
1631
|
+
function resolveBashTimeout(provided) {
|
|
1632
|
+
return typeof provided === "number" && provided > 0 ? provided : 600;
|
|
1633
|
+
}
|
|
1634
|
+
const bashDefaultTimeoutExtension = (pi) => {
|
|
1635
|
+
pi.on("tool_call", async (event) => {
|
|
1636
|
+
if (event.toolName !== "bash") return;
|
|
1637
|
+
event.input.timeout = resolveBashTimeout(event.input.timeout);
|
|
1638
|
+
});
|
|
1639
|
+
};
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/channel-context-ref.ts
|
|
1642
|
+
/**
|
|
1643
|
+
* The worker injects only a reference — `{ channel, messageId }` — into the
|
|
1644
|
+
* sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
1645
|
+
*
|
|
1646
|
+
* The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
|
|
1647
|
+
* `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
|
|
1648
|
+
* `@createinc/*` dependencies — importing that package would pull the whole
|
|
1649
|
+
* platform channel stack (Slack/email/Linq SDKs, messaging) in just to read
|
|
1650
|
+
* two fields. So we validate the (stable) shape locally instead.
|
|
1651
|
+
*/
|
|
1652
|
+
const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
|
|
1653
|
+
/**
|
|
1654
|
+
* Pull the current turn's message id out of the ref blob. Returns `null`
|
|
1655
|
+
* outside a turn (cron / system) or if the blob is missing/malformed.
|
|
1656
|
+
*/
|
|
1657
|
+
function extractMessageId(channelContextJson) {
|
|
1658
|
+
if (!channelContextJson) return null;
|
|
1659
|
+
try {
|
|
1660
|
+
const parsed = channelContextRefSchema.safeParse(JSON.parse(channelContextJson));
|
|
1661
|
+
return parsed.success ? parsed.data.messageId : null;
|
|
1662
|
+
} catch {
|
|
1663
|
+
return null;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/api-url.ts
|
|
1668
|
+
/**
|
|
1669
|
+
* Resolve the Skydive API base URL from the sandbox env. Newly-provisioned
|
|
1670
|
+
* sandboxes get `ANYONE_API_URL` (see `apps/anyone/infra` stack); `SKYDIVE_API_URL`
|
|
1671
|
+
* is the legacy name that only already-provisioned sandboxes still carry, and
|
|
1672
|
+
* it won't be injected going forward. We check the legacy name first (matching
|
|
1673
|
+
* the other sandbox env readers, e.g. `platform.ts` and `anyone-platform-cli`)
|
|
1674
|
+
* and fall back to the current one. Returns `null` when neither is set (e.g.
|
|
1675
|
+
* local dev with no sandbox), which callers treat as "API unavailable".
|
|
1676
|
+
*/
|
|
1677
|
+
function apiBaseUrl() {
|
|
1678
|
+
return process.env.SKYDIVE_API_URL ?? process.env.ANYONE_API_URL ?? null;
|
|
1679
|
+
}
|
|
1680
|
+
//#endregion
|
|
1681
|
+
//#region src/extensions/platform.ts
|
|
1682
|
+
const HEARTBEAT_THROTTLE_MS = 6e4;
|
|
1683
|
+
const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1684
|
+
const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
|
|
1685
|
+
const DAEMON_URL = "http://localhost:38994";
|
|
1686
|
+
const log$10 = logger.child({ module: "platform-ext" });
|
|
1687
|
+
function sandboxClient() {
|
|
1688
|
+
const apiUrl = apiBaseUrl();
|
|
1689
|
+
if (!apiUrl) return null;
|
|
1690
|
+
return hc(`${apiUrl}/api/v1/sandbox`);
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Fetch every harness feature flag in one GET (`{ contextManagement, subagent,
|
|
1694
|
+
* ... }` — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
|
|
1695
|
+
* null when indeterminate (no api url, or the request failed) so the shared
|
|
1696
|
+
* poller keeps the last-known values rather than flipping on a transient error.
|
|
1697
|
+
* This is the single fetch behind `feature-flags-poll.ts`; extensions read the
|
|
1698
|
+
* polled values there instead of issuing their own GET.
|
|
1699
|
+
*/
|
|
1700
|
+
async function fetchHarnessFlags() {
|
|
1701
|
+
const client = sandboxClient();
|
|
1702
|
+
if (!client) return null;
|
|
1703
|
+
try {
|
|
1704
|
+
const res = await client["feature-flags"].$get();
|
|
1705
|
+
if (!res.ok) {
|
|
1706
|
+
log$10.debug({
|
|
1707
|
+
status: res.status,
|
|
1708
|
+
event: "feature_flags_fetch_failed"
|
|
1709
|
+
}, "feature-flags fetch failed");
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
const body = await res.json();
|
|
1713
|
+
return {
|
|
1714
|
+
contextManagement: body.contextManagement ?? null,
|
|
1715
|
+
subagent: body.subagent ?? null
|
|
1716
|
+
};
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
log$10.debug({
|
|
1719
|
+
err,
|
|
1720
|
+
event: "feature_flags_fetch_error"
|
|
1721
|
+
}, "feature-flags request errored");
|
|
1722
|
+
return null;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
function postHeartbeat({ messageId }) {
|
|
1726
|
+
const client = sandboxClient();
|
|
1727
|
+
if (!client) return;
|
|
1728
|
+
client.heartbeat.$post({ json: { messageId } }).catch((err) => {
|
|
1729
|
+
log$10.debug({
|
|
1730
|
+
err,
|
|
1731
|
+
event: "heartbeat_failed"
|
|
1732
|
+
}, "heartbeat failed");
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
async function resolveConversationFromApi(messageId) {
|
|
1736
|
+
const client = sandboxClient();
|
|
1737
|
+
if (!client) return null;
|
|
1738
|
+
try {
|
|
1739
|
+
const res = await client["message-conversation"].$get({ query: { messageId } });
|
|
1740
|
+
if (!res.ok) {
|
|
1741
|
+
log$10.warn({
|
|
1742
|
+
status: res.status,
|
|
1743
|
+
messageId,
|
|
1744
|
+
event: "resolve_conversation_failed"
|
|
1745
|
+
}, "resolve conversation failed");
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
return (await res.json()).conversationId ?? null;
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
log$10.warn({
|
|
1751
|
+
err,
|
|
1752
|
+
messageId,
|
|
1753
|
+
event: "resolve_conversation_error"
|
|
1754
|
+
}, "resolve conversation request errored");
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
async function postBackgroundTaskDone({ messageId, content }) {
|
|
1759
|
+
const client = sandboxClient();
|
|
1760
|
+
if (!client) throw new Error("no api url for bg-task-done");
|
|
1761
|
+
const res = await client["bg-task-done"].$post({ json: {
|
|
1762
|
+
messageId,
|
|
1763
|
+
content
|
|
1764
|
+
} });
|
|
1765
|
+
if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
|
|
1766
|
+
}
|
|
1767
|
+
async function postSubagentSpawn({ messageId, tasks }) {
|
|
1768
|
+
const client = sandboxClient();
|
|
1769
|
+
if (!client) throw new Error("no api url for subagent-spawn");
|
|
1770
|
+
const res = await client["subagent-spawn"].$post({ json: {
|
|
1771
|
+
messageId,
|
|
1772
|
+
tasks
|
|
1773
|
+
} });
|
|
1774
|
+
if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
|
|
1775
|
+
return { taskIds: (await res.json()).taskIds };
|
|
1776
|
+
}
|
|
1777
|
+
function createHeartbeatThrottle({ messageId }) {
|
|
1778
|
+
let lastAt = 0;
|
|
1779
|
+
let pending = null;
|
|
1780
|
+
function send() {
|
|
1781
|
+
const now = Date.now();
|
|
1782
|
+
if (now - lastAt < HEARTBEAT_THROTTLE_MS) {
|
|
1783
|
+
if (!pending) pending = setTimeout(() => {
|
|
1784
|
+
pending = null;
|
|
1785
|
+
send();
|
|
1786
|
+
}, HEARTBEAT_THROTTLE_MS - (now - lastAt));
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
lastAt = now;
|
|
1790
|
+
postHeartbeat({ messageId });
|
|
1791
|
+
}
|
|
1792
|
+
function cancel() {
|
|
1793
|
+
if (pending) {
|
|
1794
|
+
clearTimeout(pending);
|
|
1795
|
+
pending = null;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return {
|
|
1799
|
+
send,
|
|
1800
|
+
cancel
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
function createToolHeartbeat({ messageId }) {
|
|
1804
|
+
const activeToolCalls = /* @__PURE__ */ new Set();
|
|
1805
|
+
let interval = null;
|
|
1806
|
+
let heartbeatCount = 0;
|
|
1807
|
+
function stop() {
|
|
1808
|
+
if (interval) {
|
|
1809
|
+
clearInterval(interval);
|
|
1810
|
+
interval = null;
|
|
1811
|
+
}
|
|
1812
|
+
heartbeatCount = 0;
|
|
1813
|
+
}
|
|
1814
|
+
function start() {
|
|
1815
|
+
if (interval) return;
|
|
1816
|
+
heartbeatCount = 0;
|
|
1817
|
+
interval = setInterval(() => {
|
|
1818
|
+
if (activeToolCalls.size === 0) {
|
|
1819
|
+
stop();
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
heartbeatCount++;
|
|
1823
|
+
if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
|
|
1824
|
+
log$10.warn({
|
|
1825
|
+
heartbeatCount,
|
|
1826
|
+
activeToolCalls: [...activeToolCalls]
|
|
1827
|
+
}, "tool heartbeat max reached, stopping");
|
|
1828
|
+
stop();
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
postHeartbeat({ messageId });
|
|
1832
|
+
}, TOOL_HEARTBEAT_INTERVAL_MS);
|
|
1833
|
+
}
|
|
1834
|
+
return {
|
|
1835
|
+
onToolStart(toolCallId) {
|
|
1836
|
+
activeToolCalls.add(toolCallId);
|
|
1837
|
+
start();
|
|
1838
|
+
},
|
|
1839
|
+
onToolEnd(toolCallId) {
|
|
1840
|
+
activeToolCalls.delete(toolCallId);
|
|
1841
|
+
if (activeToolCalls.size === 0) stop();
|
|
1842
|
+
},
|
|
1843
|
+
stop,
|
|
1844
|
+
get activeCount() {
|
|
1845
|
+
return activeToolCalls.size;
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
function postToDaemon(path, body) {
|
|
1850
|
+
fetch(`${DAEMON_URL}${path}`, {
|
|
1851
|
+
method: "POST",
|
|
1852
|
+
headers: { "content-type": "application/json" },
|
|
1853
|
+
body: JSON.stringify(body)
|
|
1854
|
+
}).catch((err) => {
|
|
1855
|
+
log$10.debug({
|
|
1856
|
+
err,
|
|
1857
|
+
path,
|
|
1858
|
+
event: "daemon_post_failed"
|
|
1859
|
+
}, "daemon POST failed");
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
function createPlatformExtensions({ sessionId, channelContext }) {
|
|
1863
|
+
return (pi) => {
|
|
1864
|
+
log$10.info({
|
|
1865
|
+
sessionId,
|
|
1866
|
+
hasChannelContext: Boolean(channelContext)
|
|
1867
|
+
}, "platform extension initialized");
|
|
1868
|
+
const messageId = extractMessageId(channelContext);
|
|
1869
|
+
const throttle = createHeartbeatThrottle({ messageId });
|
|
1870
|
+
const deferHeartbeat = () => {
|
|
1871
|
+
setTimeout(throttle.send, 0);
|
|
1872
|
+
};
|
|
1873
|
+
pi.on("agent_start", deferHeartbeat);
|
|
1874
|
+
pi.on("tool_execution_start", deferHeartbeat);
|
|
1875
|
+
pi.on("tool_execution_end", deferHeartbeat);
|
|
1876
|
+
pi.on("tool_execution_update", deferHeartbeat);
|
|
1877
|
+
pi.on("agent_end", deferHeartbeat);
|
|
1878
|
+
const toolHeartbeat = createToolHeartbeat({ messageId });
|
|
1879
|
+
pi.on("tool_execution_start", (event) => {
|
|
1880
|
+
toolHeartbeat.onToolStart(event.toolCallId);
|
|
1881
|
+
});
|
|
1882
|
+
pi.on("tool_execution_end", (event) => {
|
|
1883
|
+
toolHeartbeat.onToolEnd(event.toolCallId);
|
|
1884
|
+
});
|
|
1885
|
+
pi.on("agent_end", () => {
|
|
1886
|
+
toolHeartbeat.stop();
|
|
1887
|
+
throttle.cancel();
|
|
1888
|
+
});
|
|
1889
|
+
postToDaemon("/session", {
|
|
1890
|
+
sessionId,
|
|
1891
|
+
channelContext
|
|
1892
|
+
});
|
|
1893
|
+
pi.on("tool_call", (event) => {
|
|
1894
|
+
postToDaemon("/session/tool-event", {
|
|
1895
|
+
sessionId,
|
|
1896
|
+
kind: "tool_call",
|
|
1897
|
+
toolName: event.toolName,
|
|
1898
|
+
toolCallId: event.toolCallId,
|
|
1899
|
+
input: event.input
|
|
1900
|
+
});
|
|
1901
|
+
});
|
|
1902
|
+
pi.on("tool_result", (event) => {
|
|
1903
|
+
postToDaemon("/session/tool-event", {
|
|
1904
|
+
sessionId,
|
|
1905
|
+
kind: "tool_result",
|
|
1906
|
+
toolName: event.toolName,
|
|
1907
|
+
toolCallId: event.toolCallId,
|
|
1908
|
+
isError: event.isError,
|
|
1909
|
+
input: event.input
|
|
1910
|
+
});
|
|
1911
|
+
});
|
|
1912
|
+
pi.on("agent_end", () => {
|
|
1913
|
+
log$10.info({ sessionId }, "session ending");
|
|
1914
|
+
postToDaemon("/session/end", { sessionId });
|
|
1915
|
+
});
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/extensions/feature-flags-poll.ts
|
|
1920
|
+
/**
|
|
1921
|
+
* Shared harness feature-flag poll.
|
|
1922
|
+
*
|
|
1923
|
+
* The api exposes one `/feature-flags` GET that returns every harness flag in a
|
|
1924
|
+
* single response (`{ contextManagement, subagent, commandFlags }` — see
|
|
1925
|
+
* apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
|
|
1926
|
+
* extension issuing its own GET — and, worse, a *blocking* GET on the
|
|
1927
|
+
* pre-first-token `session_start` path — a single background poller fetches
|
|
1928
|
+
* that response once per interval and fans the values out to every subscriber.
|
|
1929
|
+
*
|
|
1930
|
+
* Why one poller: the subagent extension gates its tool registration on the
|
|
1931
|
+
* `subagent` flag. If it awaited a fresh GET inside `session_start` the tool
|
|
1932
|
+
* schema (part of the prefill) couldn't be finalized until a serial
|
|
1933
|
+
* sandbox→api round-trip settled, adding a net-new pre-token network hop on
|
|
1934
|
+
* every session, flag on or off. Reading the last-polled value instead keeps
|
|
1935
|
+
* the hot path allocation-only. A cold cache reads as `null` (fail-open to
|
|
1936
|
+
* unregistered); a newly-flipped flag takes effect on the next poll, matching
|
|
1937
|
+
* how context-management already treats its flag.
|
|
1938
|
+
*
|
|
1939
|
+
* The poll is fire-and-forget and self-unref'd — it never keeps the process
|
|
1940
|
+
* alive and an indeterminate result (no api url / transient failure) leaves the
|
|
1941
|
+
* last-known values untouched so a blip can't silently flip behavior.
|
|
1942
|
+
*/
|
|
1943
|
+
const log$9 = logger.child({ module: "feature-flags-poll" });
|
|
1944
|
+
const FLAG_POLL_INTERVAL_MS = 6e4;
|
|
1945
|
+
let contextManagement = null;
|
|
1946
|
+
let subagent = null;
|
|
1947
|
+
const subscribers = {
|
|
1948
|
+
contextManagement: /* @__PURE__ */ new Set(),
|
|
1949
|
+
subagent: /* @__PURE__ */ new Set()
|
|
1950
|
+
};
|
|
1951
|
+
let pollerStarted = false;
|
|
1952
|
+
/** Last-polled value of a flag, or `null` if not yet resolved. */
|
|
1953
|
+
function getPolledFlag(name) {
|
|
1954
|
+
return name === "contextManagement" ? contextManagement : subagent;
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Subscribe to changes of a flag. The callback fires only on a *transition*
|
|
1958
|
+
* (skipped while the value is unchanged), so a subscriber registered before the
|
|
1959
|
+
* first poll still learns the initial value. Returns an unsubscribe fn.
|
|
1960
|
+
*/
|
|
1961
|
+
function onFlagChange(name, cb) {
|
|
1962
|
+
subscribers[name].add(cb);
|
|
1963
|
+
return () => subscribers[name].delete(cb);
|
|
1964
|
+
}
|
|
1965
|
+
function apply(name, next) {
|
|
1966
|
+
if (next === null) return;
|
|
1967
|
+
const prev = name === "contextManagement" ? contextManagement : subagent;
|
|
1968
|
+
if (name === "contextManagement") contextManagement = next;
|
|
1969
|
+
else subagent = next;
|
|
1970
|
+
if (next !== prev) for (const cb of subscribers[name]) try {
|
|
1971
|
+
cb(next);
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
log$9.warn({
|
|
1974
|
+
err,
|
|
1975
|
+
flag: name
|
|
1976
|
+
}, "flag subscriber threw");
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
async function pollOnce() {
|
|
1980
|
+
const flags = await fetchHarnessFlags();
|
|
1981
|
+
if (!flags) return;
|
|
1982
|
+
apply("contextManagement", flags.contextManagement ?? null);
|
|
1983
|
+
apply("subagent", flags.subagent ?? null);
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Start the shared background poll (idempotent). No-op when there's no
|
|
1987
|
+
* phone-home channel (bare CLI): there's nothing to poll and callers keep their
|
|
1988
|
+
* env/boot default. Kicks an immediate poll, then repeats on an interval that
|
|
1989
|
+
* does not keep the process alive.
|
|
1990
|
+
*/
|
|
1991
|
+
function startFeatureFlagPoller() {
|
|
1992
|
+
if (pollerStarted || !hasFlagSource()) return;
|
|
1993
|
+
pollerStarted = true;
|
|
1994
|
+
pollOnce();
|
|
1995
|
+
setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
|
|
1996
|
+
}
|
|
1997
|
+
//#endregion
|
|
1998
|
+
//#region src/extensions/context-management-trim.ts
|
|
1999
|
+
const CLEARED_PLACEHOLDER = "[old tool result cleared to save context — re-run the tool or re-read the source to recover it]";
|
|
2000
|
+
/** Rough token estimate (~4 chars/token); good enough for trigger decisions. */
|
|
2001
|
+
function estimateTokens(text) {
|
|
2002
|
+
return Math.ceil(text.length / 4);
|
|
2003
|
+
}
|
|
2004
|
+
function isToolResult(message) {
|
|
2005
|
+
return message.role === "toolResult";
|
|
2006
|
+
}
|
|
2007
|
+
function isTextBlock(block) {
|
|
2008
|
+
return block.type === "text";
|
|
2009
|
+
}
|
|
2010
|
+
function joinText(content) {
|
|
2011
|
+
return content.filter(isTextBlock).map((block) => block.text).join("");
|
|
2012
|
+
}
|
|
2013
|
+
function isCleared(content) {
|
|
2014
|
+
const first = content[0];
|
|
2015
|
+
return content.length === 1 && first !== void 0 && first.type === "text" && first.text === CLEARED_PLACEHOLDER;
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Returns a head/tail excerpt of `text` that fits within `maxBytes`, or null
|
|
2019
|
+
* if `text` is already within budget. Idempotent: the excerpt itself is within
|
|
2020
|
+
* budget, so re-running yields null.
|
|
2021
|
+
*/
|
|
2022
|
+
function excerpt(text, maxBytes) {
|
|
2023
|
+
if (Buffer.byteLength(text) <= maxBytes) return null;
|
|
2024
|
+
const removedNotice = (removed) => `\n\n…[${removed} bytes truncated — re-run the tool or re-read the source for the full output]…\n\n`;
|
|
2025
|
+
const sample = removedNotice(Buffer.byteLength(text));
|
|
2026
|
+
const noticeBytes = Buffer.byteLength(sample);
|
|
2027
|
+
const budget = Math.max(maxBytes - noticeBytes, 0);
|
|
2028
|
+
if (budget < 64) return text.slice(0, Math.max(maxBytes, 0));
|
|
2029
|
+
const headChars = Math.floor(budget * .6);
|
|
2030
|
+
const tailChars = Math.floor(budget * .3);
|
|
2031
|
+
const head = text.slice(0, headChars);
|
|
2032
|
+
const tail = text.slice(text.length - tailChars);
|
|
2033
|
+
return `${head}${removedNotice(Buffer.byteLength(text) - Buffer.byteLength(head) - Buffer.byteLength(tail))}${tail}`;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Transforms the message list in place (callers pass a clone) applying L1
|
|
2037
|
+
* insertion trimming and L2/L3 microcompact. Never throws.
|
|
2038
|
+
*/
|
|
2039
|
+
function transformContextMessages(messages, config, now) {
|
|
2040
|
+
const stats = {
|
|
2041
|
+
trimmedResults: 0,
|
|
2042
|
+
trimmedTokens: 0,
|
|
2043
|
+
clearedResults: 0,
|
|
2044
|
+
clearedTokens: 0,
|
|
2045
|
+
clearTrigger: "none",
|
|
2046
|
+
remainingToolTokens: 0
|
|
2047
|
+
};
|
|
2048
|
+
const excluded = new Set(config.excludeTools);
|
|
2049
|
+
const toolResults = messages.filter((message) => isToolResult(message) && !excluded.has(message.toolName));
|
|
2050
|
+
for (const result of toolResults) {
|
|
2051
|
+
if (isCleared(result.content)) continue;
|
|
2052
|
+
const joined = joinText(result.content);
|
|
2053
|
+
const trimmed = excerpt(joined, config.perResultMaxBytes);
|
|
2054
|
+
if (trimmed === null) continue;
|
|
2055
|
+
const images = result.content.filter((block) => block.type === "image");
|
|
2056
|
+
result.content = [{
|
|
2057
|
+
type: "text",
|
|
2058
|
+
text: trimmed
|
|
2059
|
+
}, ...images];
|
|
2060
|
+
stats.trimmedResults += 1;
|
|
2061
|
+
stats.trimmedTokens += estimateTokens(joined) - estimateTokens(trimmed);
|
|
2062
|
+
}
|
|
2063
|
+
const clearable = toolResults.filter((result) => !isCleared(result.content)).slice(0, Math.max(toolResults.length - config.keepRecentToolResults, 0));
|
|
2064
|
+
const clearableTokens = clearable.reduce((sum, result) => sum + estimateTokens(joinText(result.content)), 0);
|
|
2065
|
+
const lastActivity = messages.reduce((max, message) => {
|
|
2066
|
+
if (message.role !== "assistant" && message.role !== "toolResult") return max;
|
|
2067
|
+
return Math.max(max, message.timestamp ?? 0);
|
|
2068
|
+
}, 0);
|
|
2069
|
+
const isCold = lastActivity > 0 && now - lastActivity > config.coldCacheGapSeconds * 1e3;
|
|
2070
|
+
if (clearable.length > 0 && (isCold || clearableTokens >= config.warmClearTriggerTokens && clearableTokens >= config.clearAtLeastTokens)) {
|
|
2071
|
+
stats.clearTrigger = isCold ? "cold" : "warm";
|
|
2072
|
+
for (const result of clearable) {
|
|
2073
|
+
stats.clearedTokens += estimateTokens(joinText(result.content));
|
|
2074
|
+
result.content = [{
|
|
2075
|
+
type: "text",
|
|
2076
|
+
text: CLEARED_PLACEHOLDER
|
|
2077
|
+
}];
|
|
2078
|
+
stats.clearedResults += 1;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
stats.remainingToolTokens = toolResults.reduce((sum, result) => sum + estimateTokens(joinText(result.content)), 0);
|
|
2082
|
+
return {
|
|
2083
|
+
messages,
|
|
2084
|
+
stats
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
//#endregion
|
|
2088
|
+
//#region src/extensions/context-management.ts
|
|
2089
|
+
const log$8 = logger.child({ module: "context-management-extension" });
|
|
2090
|
+
function isAnthropicMessagesPayload(payload) {
|
|
2091
|
+
if (typeof payload !== "object" || payload === null) return false;
|
|
2092
|
+
const candidate = payload;
|
|
2093
|
+
return typeof candidate.model === "string" && candidate.model.includes("claude") && Array.isArray(candidate.messages);
|
|
2094
|
+
}
|
|
2095
|
+
function buildAnthropicEdits(config) {
|
|
2096
|
+
return [{
|
|
2097
|
+
type: "clear_tool_uses_20250919",
|
|
2098
|
+
trigger: {
|
|
2099
|
+
type: "input_tokens",
|
|
2100
|
+
value: config.warmClearTriggerTokens
|
|
2101
|
+
},
|
|
2102
|
+
keep: {
|
|
2103
|
+
type: "tool_uses",
|
|
2104
|
+
value: config.keepRecentToolResults
|
|
2105
|
+
},
|
|
2106
|
+
clear_at_least: {
|
|
2107
|
+
type: "input_tokens",
|
|
2108
|
+
value: config.clearAtLeastTokens
|
|
2109
|
+
},
|
|
2110
|
+
...config.excludeTools.length > 0 ? { exclude_tools: config.excludeTools } : {}
|
|
2111
|
+
}, { type: "clear_thinking_20251015" }];
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* L4 body-field injection (pure). Returns the payload untouched unless the
|
|
2115
|
+
* feature is on, native edits are enabled, the payload is an Anthropic messages
|
|
2116
|
+
* request, and it doesn't already carry a `context_management` field.
|
|
2117
|
+
*/
|
|
2118
|
+
function applyNativeAnthropicEdits(payload, config) {
|
|
2119
|
+
if (!config.enabled || !config.nativeAnthropicEdits) return payload;
|
|
2120
|
+
if (!isAnthropicMessagesPayload(payload)) return payload;
|
|
2121
|
+
if (payload.context_management !== void 0) return payload;
|
|
2122
|
+
return {
|
|
2123
|
+
...payload,
|
|
2124
|
+
context_management: { edits: buildAnthropicEdits(config) }
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* L1–L3 transform behind the enabled gate (pure). When disabled, returns the
|
|
2129
|
+
* messages untouched and `stats: null`.
|
|
2130
|
+
*/
|
|
2131
|
+
function transformContextIfEnabled(messages, config, now) {
|
|
2132
|
+
if (!config.enabled) return {
|
|
2133
|
+
messages,
|
|
2134
|
+
stats: null
|
|
2135
|
+
};
|
|
2136
|
+
const result = transformContextMessages(messages, config, now);
|
|
2137
|
+
return {
|
|
2138
|
+
messages: result.messages,
|
|
2139
|
+
stats: result.stats
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Builds the context-management extension. Reads the live (override-aware)
|
|
2144
|
+
* config from the runtime holder on every call, so the platform flag can flip
|
|
2145
|
+
* the feature on/off mid-session.
|
|
2146
|
+
*/
|
|
2147
|
+
function createContextManagementExtension() {
|
|
2148
|
+
return (pi) => {
|
|
2149
|
+
const initial = getContextManagementConfig();
|
|
2150
|
+
if (!initial.enabled && !hasFlagSource()) return;
|
|
2151
|
+
setContextManagementFlagOverride(getPolledFlag("contextManagement"));
|
|
2152
|
+
onFlagChange("contextManagement", (enabled) => {
|
|
2153
|
+
setContextManagementFlagOverride(enabled);
|
|
2154
|
+
log$8.info({
|
|
2155
|
+
event: "context_management_flag_update",
|
|
2156
|
+
enabled
|
|
2157
|
+
}, "context-management flag updated from platform");
|
|
2158
|
+
});
|
|
2159
|
+
startFeatureFlagPoller();
|
|
2160
|
+
log$8.info({
|
|
2161
|
+
event: "context_management_registered",
|
|
2162
|
+
enabled: initial.enabled,
|
|
2163
|
+
flagSource: hasFlagSource(),
|
|
2164
|
+
perResultMaxBytes: initial.perResultMaxBytes,
|
|
2165
|
+
keepRecentToolResults: initial.keepRecentToolResults,
|
|
2166
|
+
nativeAnthropicEdits: initial.nativeAnthropicEdits
|
|
2167
|
+
}, "context-management handlers registered");
|
|
2168
|
+
pi.on("context", (event) => {
|
|
2169
|
+
const { messages } = event;
|
|
2170
|
+
try {
|
|
2171
|
+
const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
|
|
2172
|
+
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$8.info({
|
|
2173
|
+
event: "context_management_applied",
|
|
2174
|
+
...result.stats
|
|
2175
|
+
}, "trimmed/cleared tool output before LLM call");
|
|
2176
|
+
return { messages: result.messages };
|
|
2177
|
+
} catch (err) {
|
|
2178
|
+
log$8.error({
|
|
2179
|
+
err,
|
|
2180
|
+
event: "context_management_transform_failed"
|
|
2181
|
+
}, "context transform failed; passing messages through unchanged");
|
|
2182
|
+
return { messages };
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
pi.on("before_provider_request", (event) => applyNativeAnthropicEdits(event.payload, getContextManagementConfig()));
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
//#endregion
|
|
2189
|
+
//#region src/extensions/current-time.ts
|
|
2190
|
+
const log$7 = logger.child({ module: "current-time-extension" });
|
|
2191
|
+
const PI_DATE_LINE = /^Current date:.*$/m;
|
|
2192
|
+
function formatCurrentTimeLine(now) {
|
|
2193
|
+
return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
|
|
2194
|
+
weekday: "long",
|
|
2195
|
+
timeZone: "UTC"
|
|
2196
|
+
}).format(now)} UTC)`;
|
|
2197
|
+
}
|
|
2198
|
+
const currentTimeExtension = (pi) => {
|
|
2199
|
+
pi.on("before_agent_start", (event) => {
|
|
2200
|
+
const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
|
|
2201
|
+
const base = event.systemPrompt;
|
|
2202
|
+
if (PI_DATE_LINE.test(base)) {
|
|
2203
|
+
log$7.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
|
|
2204
|
+
return { systemPrompt: base.replace(PI_DATE_LINE, line) };
|
|
2205
|
+
}
|
|
2206
|
+
return { systemPrompt: `${base}\n${line}` };
|
|
2207
|
+
});
|
|
2208
|
+
};
|
|
2209
|
+
//#endregion
|
|
2210
|
+
//#region src/memory.ts
|
|
2211
|
+
/**
|
|
2212
|
+
* In-harness memory index builder.
|
|
2213
|
+
*
|
|
2214
|
+
* The agent has an agent-level file-based memory at `<cwd>/.memory/`,
|
|
2215
|
+
* organized by directory:
|
|
2216
|
+
*
|
|
2217
|
+
* .memory/users/<id>-<name>/<topic>.md
|
|
2218
|
+
* .memory/projects/<project_slug>/<topic>.md
|
|
2219
|
+
* .memory/feedback/<topic>.md
|
|
2220
|
+
* .memory/reference/<topic>.md
|
|
2221
|
+
*
|
|
2222
|
+
* The path encodes type and subject (for `users/`, the subject is the
|
|
2223
|
+
* person's stable id with a readable name suffix); each `.md` file's
|
|
2224
|
+
* frontmatter only carries `name` and `description`.
|
|
2225
|
+
*
|
|
2226
|
+
* `buildMemoryIndex` walks `.memory/` by type directory, reads only the
|
|
2227
|
+
* frontmatter of each `.md` (open fd → read first ~4KB → close, in
|
|
2228
|
+
* parallel), and renders a markdown index grouped by type and (where
|
|
2229
|
+
* applicable) by subject. Files outside the four type directories are
|
|
2230
|
+
* ignored. Bodies are never read — the agent loads a specific memory's
|
|
2231
|
+
* body on demand via the `read` tool when the index entry says it's
|
|
2232
|
+
* relevant.
|
|
2233
|
+
*
|
|
2234
|
+
* Mtime cache keyed by cwd — within the lifetime of a sandbox the cwd
|
|
2235
|
+
* is fixed, so this is effectively a single-entry cache. Cache invalidates
|
|
2236
|
+
* when any `.md` in the tree is added/modified/deleted; turns where
|
|
2237
|
+
* memory didn't change reuse the cached string.
|
|
2238
|
+
*
|
|
2239
|
+
* Frontmatter is parsed as YAML (`yaml` package) and validated with a
|
|
2240
|
+
* zod schema — files that don't match the shape are dropped from the
|
|
2241
|
+
* index. The same schema can be reused at write time if we want to
|
|
2242
|
+
* validate before commit.
|
|
2243
|
+
*/
|
|
2244
|
+
const FRONTMATTER_READ_BYTES = 4096;
|
|
2245
|
+
const FrontmatterSchema = z.object({
|
|
2246
|
+
name: z.string().min(1),
|
|
2247
|
+
description: z.string().min(1)
|
|
2248
|
+
}).passthrough();
|
|
2249
|
+
const MEMORY_DIRNAME = ".memory";
|
|
2250
|
+
const TYPE_DIRS = [
|
|
2251
|
+
"users",
|
|
2252
|
+
"projects",
|
|
2253
|
+
"feedback",
|
|
2254
|
+
"reference"
|
|
2255
|
+
];
|
|
2256
|
+
const TYPES_WITH_SUBJECT = new Set(["users", "projects"]);
|
|
2257
|
+
const TYPE_LABELS = {
|
|
2258
|
+
users: "Users",
|
|
2259
|
+
projects: "Projects",
|
|
2260
|
+
feedback: "Feedback",
|
|
2261
|
+
reference: "Reference"
|
|
2262
|
+
};
|
|
2263
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2264
|
+
/**
|
|
2265
|
+
* Returns:
|
|
2266
|
+
* - `null` if `.memory/` doesn't exist
|
|
2267
|
+
* - `""` if the dir exists but contains nothing in the requested scope
|
|
2268
|
+
* - rendered markdown body (no surrounding header — caller wraps)
|
|
2269
|
+
*
|
|
2270
|
+
* The mtime-keyed cache stores the raw walked entries (the cost is the FS
|
|
2271
|
+
* walk); filtering by scope is cheap and runs per call, so two turns with
|
|
2272
|
+
* different scopes on the same cwd render correctly from one cached walk.
|
|
2273
|
+
*/
|
|
2274
|
+
async function buildMemoryIndex({ cwd, scope }) {
|
|
2275
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
2276
|
+
const maxMtimeMs = await maxMtimeAcrossDir(memoryDirAbs);
|
|
2277
|
+
if (maxMtimeMs === null) {
|
|
2278
|
+
cache.delete(cwd);
|
|
2279
|
+
return null;
|
|
2280
|
+
}
|
|
2281
|
+
let cached = cache.get(cwd);
|
|
2282
|
+
if (!cached || cached.builtAtMs < maxMtimeMs) {
|
|
2283
|
+
cached = {
|
|
2284
|
+
entries: await collectEntries(memoryDirAbs, cwd),
|
|
2285
|
+
builtAtMs: Date.now()
|
|
2286
|
+
};
|
|
2287
|
+
cache.set(cwd, cached);
|
|
2288
|
+
}
|
|
2289
|
+
const visible = cached.entries.filter((entry) => scope.kind === "user" ? entry.type === "users" && entry.subject?.startsWith(scope.userId) === true : entry.type !== "users");
|
|
2290
|
+
return visible.length === 0 ? "" : renderIndex(visible);
|
|
2291
|
+
}
|
|
2292
|
+
async function maxMtimeAcrossDir(dir) {
|
|
2293
|
+
let dirStat;
|
|
2294
|
+
try {
|
|
2295
|
+
dirStat = await stat(dir);
|
|
2296
|
+
} catch {
|
|
2297
|
+
return null;
|
|
2298
|
+
}
|
|
2299
|
+
if (!dirStat.isDirectory()) return null;
|
|
2300
|
+
let max = dirStat.mtimeMs;
|
|
2301
|
+
const files = [];
|
|
2302
|
+
await walkMdFiles(dir, files);
|
|
2303
|
+
const fileStats = await Promise.all(files.map((f) => stat(f).catch(() => null)));
|
|
2304
|
+
for (const s of fileStats) if (s && s.mtimeMs > max) max = s.mtimeMs;
|
|
2305
|
+
return max;
|
|
2306
|
+
}
|
|
2307
|
+
async function walkMdFiles(dir, out) {
|
|
2308
|
+
let entries;
|
|
2309
|
+
try {
|
|
2310
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2311
|
+
} catch {
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
await Promise.all(entries.map(async (entry) => {
|
|
2315
|
+
const full = join(dir, entry.name);
|
|
2316
|
+
if (entry.isDirectory()) await walkMdFiles(full, out);
|
|
2317
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
|
|
2318
|
+
}));
|
|
2319
|
+
}
|
|
2320
|
+
async function listMdFilesShallow(dir) {
|
|
2321
|
+
let entries;
|
|
2322
|
+
try {
|
|
2323
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2324
|
+
} catch {
|
|
2325
|
+
return [];
|
|
2326
|
+
}
|
|
2327
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => join(dir, e.name));
|
|
2328
|
+
}
|
|
2329
|
+
async function listSubdirs(dir) {
|
|
2330
|
+
let entries;
|
|
2331
|
+
try {
|
|
2332
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2333
|
+
} catch {
|
|
2334
|
+
return [];
|
|
2335
|
+
}
|
|
2336
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
|
|
2337
|
+
}
|
|
2338
|
+
async function collectEntries(rootDirAbs, cwd) {
|
|
2339
|
+
const collected = [];
|
|
2340
|
+
await Promise.all(TYPE_DIRS.map(async (type) => {
|
|
2341
|
+
const typeDirAbs = join(rootDirAbs, type);
|
|
2342
|
+
if (TYPES_WITH_SUBJECT.has(type)) {
|
|
2343
|
+
const subjectDirs = await listSubdirs(typeDirAbs);
|
|
2344
|
+
await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
|
|
2345
|
+
const subject = basename(subjectDirAbs);
|
|
2346
|
+
const files = await listMdFilesShallow(subjectDirAbs);
|
|
2347
|
+
const parsed = await Promise.all(files.map(async (file) => {
|
|
2348
|
+
const fm = await readFrontmatterOnly(file);
|
|
2349
|
+
if (!fm?.name || !fm?.description) return null;
|
|
2350
|
+
return {
|
|
2351
|
+
name: fm.name,
|
|
2352
|
+
description: fm.description,
|
|
2353
|
+
type,
|
|
2354
|
+
subject,
|
|
2355
|
+
relPath: relative(cwd, file)
|
|
2356
|
+
};
|
|
2357
|
+
}));
|
|
2358
|
+
for (const e of parsed) if (e) collected.push(e);
|
|
2359
|
+
}));
|
|
2360
|
+
} else {
|
|
2361
|
+
const files = await listMdFilesShallow(typeDirAbs);
|
|
2362
|
+
const parsed = await Promise.all(files.map(async (file) => {
|
|
2363
|
+
const fm = await readFrontmatterOnly(file);
|
|
2364
|
+
if (!fm?.name || !fm?.description) return null;
|
|
2365
|
+
return {
|
|
2366
|
+
name: fm.name,
|
|
2367
|
+
description: fm.description,
|
|
2368
|
+
type,
|
|
2369
|
+
subject: null,
|
|
2370
|
+
relPath: relative(cwd, file)
|
|
2371
|
+
};
|
|
2372
|
+
}));
|
|
2373
|
+
for (const e of parsed) if (e) collected.push(e);
|
|
2374
|
+
}
|
|
2375
|
+
}));
|
|
2376
|
+
return collected;
|
|
2377
|
+
}
|
|
2378
|
+
async function readFrontmatterOnly(filePath) {
|
|
2379
|
+
let fh;
|
|
2380
|
+
try {
|
|
2381
|
+
fh = await open(filePath, "r");
|
|
2382
|
+
} catch {
|
|
2383
|
+
return null;
|
|
2384
|
+
}
|
|
2385
|
+
try {
|
|
2386
|
+
const buf = Buffer.alloc(FRONTMATTER_READ_BYTES);
|
|
2387
|
+
const { bytesRead } = await fh.read(buf, 0, FRONTMATTER_READ_BYTES, 0);
|
|
2388
|
+
return parseFrontmatter(buf.toString("utf-8", 0, bytesRead));
|
|
2389
|
+
} catch {
|
|
2390
|
+
return null;
|
|
2391
|
+
} finally {
|
|
2392
|
+
await fh.close().catch(() => {});
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
function parseFrontmatter(text) {
|
|
2396
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
2397
|
+
if (!match || match[1] === void 0) return null;
|
|
2398
|
+
let parsed;
|
|
2399
|
+
try {
|
|
2400
|
+
parsed = parse(match[1]);
|
|
2401
|
+
} catch {
|
|
2402
|
+
return null;
|
|
2403
|
+
}
|
|
2404
|
+
const result = FrontmatterSchema.safeParse(parsed);
|
|
2405
|
+
return result.success ? result.data : null;
|
|
2406
|
+
}
|
|
2407
|
+
function renderIndex(entries) {
|
|
2408
|
+
const byType = {
|
|
2409
|
+
users: [],
|
|
2410
|
+
projects: [],
|
|
2411
|
+
feedback: [],
|
|
2412
|
+
reference: []
|
|
2413
|
+
};
|
|
2414
|
+
for (const e of entries) byType[e.type].push(e);
|
|
2415
|
+
const sections = [];
|
|
2416
|
+
for (const type of TYPE_DIRS) {
|
|
2417
|
+
const items = byType[type];
|
|
2418
|
+
if (items.length === 0) continue;
|
|
2419
|
+
sections.push(`### ${TYPE_LABELS[type]}`);
|
|
2420
|
+
if (TYPES_WITH_SUBJECT.has(type)) {
|
|
2421
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
2422
|
+
for (const e of items) {
|
|
2423
|
+
const subject = e.subject ?? "(unknown)";
|
|
2424
|
+
const list = bySubject.get(subject) ?? [];
|
|
2425
|
+
list.push(e);
|
|
2426
|
+
bySubject.set(subject, list);
|
|
2427
|
+
}
|
|
2428
|
+
const subjects = [...bySubject.keys()].sort();
|
|
2429
|
+
for (const subject of subjects) {
|
|
2430
|
+
sections.push(`- **${subject}**`);
|
|
2431
|
+
for (const e of bySubject.get(subject) ?? []) sections.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
2432
|
+
}
|
|
2433
|
+
} else for (const e of items) sections.push(`- \`${e.relPath}\` — ${e.description}`);
|
|
2434
|
+
sections.push("");
|
|
2435
|
+
}
|
|
2436
|
+
return sections.join("\n").trimEnd();
|
|
2437
|
+
}
|
|
2438
|
+
//#endregion
|
|
2439
|
+
//#region src/extensions/memory.ts
|
|
2440
|
+
const log$6 = logger.child({ module: "memory-extension" });
|
|
2441
|
+
/**
|
|
2442
|
+
* The standing instructions for the memory system. Always injected (even with
|
|
2443
|
+
* an empty `.memory/`) so the agent knows it can persist notes. `users/` is
|
|
2444
|
+
* described by the platform memory extension, which is the only thing that can
|
|
2445
|
+
* scope it to a person — here we just point at it.
|
|
2446
|
+
*/
|
|
2447
|
+
function memoryInstructions(cwd) {
|
|
2448
|
+
return `## Memory across conversations
|
|
2449
|
+
|
|
2450
|
+
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo. The harness builds and injects an **index** of these files (paths + one-line descriptions) into your system prompt every turn; **bodies are NOT auto-loaded** — when an index entry looks relevant, use your \`read\` tool to load that specific file.
|
|
2451
|
+
|
|
2452
|
+
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.
|
|
2453
|
+
|
|
2454
|
+
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 shown separately, scoped to whoever you're talking to.) Each file's frontmatter declares \`name\` and \`description\` (the description is what shows up in the index, so make it a one-line behavior-triggering hook). Commit and push after writing to persist it.`;
|
|
2455
|
+
}
|
|
2456
|
+
function composeBlock$1({ cwd, index }) {
|
|
2457
|
+
const instructions = memoryInstructions(cwd);
|
|
2458
|
+
if (!index || index.length === 0) return instructions;
|
|
2459
|
+
return `${instructions}\n\n## Memory index\n\n${index}`;
|
|
2460
|
+
}
|
|
2461
|
+
const memoryExtension = (pi) => {
|
|
2462
|
+
let cachedBlock = null;
|
|
2463
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2464
|
+
try {
|
|
2465
|
+
const index = await buildMemoryIndex({
|
|
2466
|
+
cwd: ctx.cwd,
|
|
2467
|
+
scope: { kind: "shared" }
|
|
2468
|
+
});
|
|
2469
|
+
cachedBlock = composeBlock$1({
|
|
2470
|
+
cwd: ctx.cwd,
|
|
2471
|
+
index
|
|
2472
|
+
});
|
|
2473
|
+
} catch (err) {
|
|
2474
|
+
log$6.warn({
|
|
2475
|
+
err,
|
|
2476
|
+
event: "memory_index_failed"
|
|
2477
|
+
}, "memory index build failed; injecting instructions only");
|
|
2478
|
+
cachedBlock = memoryInstructions(ctx.cwd);
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
pi.on("before_agent_start", (event) => {
|
|
2482
|
+
if (!cachedBlock) return void 0;
|
|
2483
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${cachedBlock}` };
|
|
2484
|
+
});
|
|
2485
|
+
};
|
|
2486
|
+
//#endregion
|
|
2487
|
+
//#region src/extensions/platform-memory.ts
|
|
2488
|
+
const log$5 = logger.child({ module: "platform-memory-extension" });
|
|
2489
|
+
/**
|
|
2490
|
+
* Resolve the human on this turn via the API, keyed by the message id.
|
|
2491
|
+
* `/sandbox/channel-context` only returns a sender for a platform-known
|
|
2492
|
+
* account (a `userId`); accountless channel senders (Slack/email) come back as
|
|
2493
|
+
* no sender. Returns `null` outside a turn, on any failure, or when there's no
|
|
2494
|
+
* account — all of which withhold `users/` memory rather than scoping it to a
|
|
2495
|
+
* non-account identity. We use the typed `hc<SandboxAppType>` client, so the
|
|
2496
|
+
* response shape can't drift from the route.
|
|
2497
|
+
*/
|
|
2498
|
+
async function resolveTurnUser(messageId) {
|
|
2499
|
+
const client = sandboxClient();
|
|
2500
|
+
if (!client) {
|
|
2501
|
+
log$5.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2504
|
+
try {
|
|
2505
|
+
const res = await client["channel-context"].$get({ query: { messageId } });
|
|
2506
|
+
if (!res.ok) {
|
|
2507
|
+
log$5.warn({
|
|
2508
|
+
event: "resolve_turn_user_failed",
|
|
2509
|
+
status: res.status
|
|
2510
|
+
}, "channel-context returned non-ok; withholding user memory");
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
const { sender } = await res.json();
|
|
2514
|
+
if (!sender) return null;
|
|
2515
|
+
return {
|
|
2516
|
+
id: sender.userId,
|
|
2517
|
+
displayName: sender.displayName
|
|
2518
|
+
};
|
|
2519
|
+
} catch (err) {
|
|
2520
|
+
log$5.warn({
|
|
2521
|
+
err,
|
|
2522
|
+
event: "resolve_turn_user_failed"
|
|
2523
|
+
}, "failed to resolve current user; withholding user memory");
|
|
2524
|
+
return null;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
/** Filesystem-safe, readable suffix for the per-user memory directory. */
|
|
2528
|
+
function slugifyName(name) {
|
|
2529
|
+
if (!name) return "user";
|
|
2530
|
+
const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
|
|
2531
|
+
return slug.length > 0 ? slug : "user";
|
|
2532
|
+
}
|
|
2533
|
+
function composeBlock({ index, user }) {
|
|
2534
|
+
const instructions = `## Current user memory
|
|
2535
|
+
|
|
2536
|
+
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.`;
|
|
2537
|
+
if (!index || index.length === 0) return instructions;
|
|
2538
|
+
return `${instructions}\n\n${index}`;
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Build the platform memory extension. `channelContext` is the per-turn ref
|
|
2542
|
+
* blob the worker injects (it carries the message id used to resolve the
|
|
2543
|
+
* user).
|
|
2544
|
+
*/
|
|
2545
|
+
function createPlatformMemoryExtension({ channelContext }) {
|
|
2546
|
+
return (pi) => {
|
|
2547
|
+
let cachedBlock = null;
|
|
2548
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2549
|
+
try {
|
|
2550
|
+
const messageId = extractMessageId(channelContext);
|
|
2551
|
+
const user = messageId ? await resolveTurnUser(messageId) : null;
|
|
2552
|
+
if (!user) {
|
|
2553
|
+
cachedBlock = null;
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
cachedBlock = composeBlock({
|
|
2557
|
+
index: await buildMemoryIndex({
|
|
2558
|
+
cwd: ctx.cwd,
|
|
2559
|
+
scope: {
|
|
2560
|
+
kind: "user",
|
|
2561
|
+
userId: user.id
|
|
2562
|
+
}
|
|
2563
|
+
}),
|
|
2564
|
+
user
|
|
2565
|
+
});
|
|
2566
|
+
} catch (err) {
|
|
2567
|
+
log$5.warn({
|
|
2568
|
+
err,
|
|
2569
|
+
event: "user_memory_index_failed"
|
|
2570
|
+
}, "user memory index build failed; skipping injection");
|
|
2571
|
+
cachedBlock = null;
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
pi.on("before_agent_start", (event) => {
|
|
2575
|
+
if (!cachedBlock) return void 0;
|
|
2576
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${cachedBlock}` };
|
|
2577
|
+
});
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
//#endregion
|
|
2581
|
+
//#region src/extensions/self-trace.ts
|
|
2582
|
+
const log$4 = logger.child({ module: "self-trace-extension" });
|
|
2583
|
+
/**
|
|
2584
|
+
* Reports the agent's own execution as OpenTelemetry spans:
|
|
2585
|
+
* agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
|
|
2586
|
+
* usage on the run span and exception events on failed tool spans.
|
|
2587
|
+
*
|
|
2588
|
+
* Reporting only — querying traces back is the platform CLI's job
|
|
2589
|
+
* (`platform trace list` / `platform trace query`), scoped server-side
|
|
2590
|
+
* to the calling agent's id.
|
|
2591
|
+
*/
|
|
2592
|
+
const selfTraceExtension = (pi) => {
|
|
2593
|
+
let sessionSpan = null;
|
|
2594
|
+
let sessionCtx = null;
|
|
2595
|
+
let runSpan = null;
|
|
2596
|
+
let runCtx = null;
|
|
2597
|
+
let turnSpan = null;
|
|
2598
|
+
let turnCtx = null;
|
|
2599
|
+
const toolSpans = /* @__PURE__ */ new Map();
|
|
2600
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2601
|
+
const tracer = getTracer();
|
|
2602
|
+
const remoteCtx = extractRemoteContext();
|
|
2603
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
2604
|
+
sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
|
|
2605
|
+
sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
|
|
2606
|
+
const sc = sessionSpan.spanContext();
|
|
2607
|
+
log$4.info({
|
|
2608
|
+
event: "self_trace_session_start",
|
|
2609
|
+
trace_id: sc.traceId,
|
|
2610
|
+
span_id: sc.spanId,
|
|
2611
|
+
model: modelId
|
|
2612
|
+
}, "self-trace session span started");
|
|
2613
|
+
});
|
|
2614
|
+
pi.on("agent_start", () => {
|
|
2615
|
+
if (!sessionCtx) return;
|
|
2616
|
+
runSpan = getTracer().startSpan("agent.run", {}, sessionCtx);
|
|
2617
|
+
runCtx = trace.setSpan(sessionCtx, runSpan);
|
|
2618
|
+
});
|
|
2619
|
+
pi.on("turn_start", (event) => {
|
|
2620
|
+
const parentCtx = runCtx ?? sessionCtx;
|
|
2621
|
+
if (!parentCtx) return;
|
|
2622
|
+
const tracer = getTracer();
|
|
2623
|
+
const turnIndex = event.turnIndex ?? 0;
|
|
2624
|
+
turnSpan = tracer.startSpan(`agent.turn.${turnIndex}`, { attributes: { "turn.index": turnIndex } }, parentCtx);
|
|
2625
|
+
turnCtx = trace.setSpan(parentCtx, turnSpan);
|
|
2626
|
+
});
|
|
2627
|
+
pi.on("tool_execution_start", (event) => {
|
|
2628
|
+
const parentCtx = turnCtx ?? runCtx ?? sessionCtx;
|
|
2629
|
+
if (!parentCtx) return;
|
|
2630
|
+
const { toolCallId, toolName } = event;
|
|
2631
|
+
const span = getTracer().startSpan(`tool.${toolName}`, { attributes: {
|
|
2632
|
+
"tool.name": toolName,
|
|
2633
|
+
"tool.call_id": toolCallId
|
|
2634
|
+
} }, parentCtx);
|
|
2635
|
+
toolSpans.set(toolCallId, span);
|
|
2636
|
+
});
|
|
2637
|
+
pi.on("tool_execution_end", (event) => {
|
|
2638
|
+
const { toolCallId, isError, result } = event;
|
|
2639
|
+
const span = toolSpans.get(toolCallId);
|
|
2640
|
+
if (!span) return;
|
|
2641
|
+
span.setAttribute("tool.is_error", isError);
|
|
2642
|
+
if (isError) {
|
|
2643
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
2644
|
+
const errorText = typeof result === "string" ? result : Array.isArray(result?.content) ? result.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : "tool execution failed";
|
|
2645
|
+
span.recordException(new Error(errorText));
|
|
2646
|
+
}
|
|
2647
|
+
span.end();
|
|
2648
|
+
toolSpans.delete(toolCallId);
|
|
2649
|
+
});
|
|
2650
|
+
pi.on("turn_end", () => {
|
|
2651
|
+
if (turnSpan) {
|
|
2652
|
+
turnSpan.end();
|
|
2653
|
+
turnSpan = null;
|
|
2654
|
+
turnCtx = null;
|
|
2655
|
+
}
|
|
2656
|
+
});
|
|
2657
|
+
pi.on("agent_end", (event) => {
|
|
2658
|
+
const ev = event;
|
|
2659
|
+
if (ev.messages && runSpan) {
|
|
2660
|
+
let totalInput = 0;
|
|
2661
|
+
let totalOutput = 0;
|
|
2662
|
+
let totalCacheRead = 0;
|
|
2663
|
+
let totalCacheWrite = 0;
|
|
2664
|
+
let totalTokens = 0;
|
|
2665
|
+
let totalCost = 0;
|
|
2666
|
+
for (const m of ev.messages) if (m?.role === "assistant" && m.usage) {
|
|
2667
|
+
totalInput += m.usage.input ?? 0;
|
|
2668
|
+
totalOutput += m.usage.output ?? 0;
|
|
2669
|
+
totalCacheRead += m.usage.cacheRead ?? 0;
|
|
2670
|
+
totalCacheWrite += m.usage.cacheWrite ?? 0;
|
|
2671
|
+
totalTokens += m.usage.totalTokens ?? 0;
|
|
2672
|
+
totalCost += m.usage.cost?.total ?? 0;
|
|
2673
|
+
}
|
|
2674
|
+
runSpan.setAttributes({
|
|
2675
|
+
"llm.usage.input_tokens": totalInput,
|
|
2676
|
+
"llm.usage.output_tokens": totalOutput,
|
|
2677
|
+
"llm.usage.cache_read_tokens": totalCacheRead,
|
|
2678
|
+
"llm.usage.cache_write_tokens": totalCacheWrite,
|
|
2679
|
+
"llm.usage.total_tokens": totalTokens,
|
|
2680
|
+
"llm.usage.cost": totalCost
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
for (const [id, span] of toolSpans) {
|
|
2684
|
+
span.end();
|
|
2685
|
+
toolSpans.delete(id);
|
|
2686
|
+
}
|
|
2687
|
+
if (turnSpan) {
|
|
2688
|
+
turnSpan.end();
|
|
2689
|
+
turnSpan = null;
|
|
2690
|
+
turnCtx = null;
|
|
2691
|
+
}
|
|
2692
|
+
if (runSpan) {
|
|
2693
|
+
runSpan.end();
|
|
2694
|
+
runSpan = null;
|
|
2695
|
+
runCtx = null;
|
|
2696
|
+
}
|
|
2697
|
+
if (sessionSpan) {
|
|
2698
|
+
sessionSpan.end();
|
|
2699
|
+
sessionSpan = null;
|
|
2700
|
+
sessionCtx = null;
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2703
|
+
};
|
|
2704
|
+
//#endregion
|
|
2705
|
+
//#region src/extensions/soul.ts
|
|
2706
|
+
/**
|
|
2707
|
+
* Soul adapter as a pi extension.
|
|
2708
|
+
*
|
|
2709
|
+
* On `session_start`, reads `soul.md` from the agent's repo and caches it.
|
|
2710
|
+
* On `before_agent_start`, appends a "Persona / Standing instructions"
|
|
2711
|
+
* section to the system prompt using the cached content. The block is
|
|
2712
|
+
* always emitted (even when `soul.md` is absent) so the agent learns the
|
|
2713
|
+
* affordance — `soul.md` is editable, picked up on the next message, and
|
|
2714
|
+
* is the place to redefine itself.
|
|
2715
|
+
*
|
|
2716
|
+
* Lives in the harness package — soul.md is content from the agent's
|
|
2717
|
+
* own git repo, not from the platform — so its handling stays here.
|
|
2718
|
+
*/
|
|
2719
|
+
const log$3 = logger.child({ module: "soul-extension" });
|
|
2720
|
+
async function readSoul(cwd) {
|
|
2721
|
+
try {
|
|
2722
|
+
return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
if (err?.code === "ENOENT") return null;
|
|
2725
|
+
log$3.warn({
|
|
2726
|
+
err,
|
|
2727
|
+
event: "soul_read_failed"
|
|
2728
|
+
}, "soul.md read failed");
|
|
2729
|
+
return null;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
function soulSection(cwd, soul) {
|
|
2733
|
+
return `# Persona / Standing instructions
|
|
2734
|
+
|
|
2735
|
+
\`${cwd}/soul.md\` is your durable self — the one place that says who you are, how you should behave, and what you're for: your persona, your principles, your standing behavioral rules, your recurring style preferences, and the capabilities that make up your purpose. It is the only identity that carries across conversations. The tool, skill, and integration lists in this prompt tell you what's *available* in this session; \`soul.md\` is what you *are*.
|
|
2736
|
+
|
|
2737
|
+
**\`soul.md\` is where behavior lives.** Any standing instruction about how you should act — a rule a user wants you to follow going forward, a tone or format preference, a workflow convention, a "from now on, always/never …" — belongs here, not in \`.memory/\`. Memory records *what happened* (facts, events, findings); soul defines *how you behave*. When a user gives you a durable behavioral rule, write it to \`soul.md\`. If you find behavioral rules that ended up in \`.memory/\`, treat that as misfiled and move them here.
|
|
2738
|
+
|
|
2739
|
+
Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Edit it (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
|
|
2740
|
+
|
|
2741
|
+
${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
|
|
2742
|
+
}
|
|
2743
|
+
const soulExtension = (pi) => {
|
|
2744
|
+
let cachedSection = null;
|
|
2745
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2746
|
+
const soul = await readSoul(ctx.cwd);
|
|
2747
|
+
cachedSection = soulSection(ctx.cwd, soul);
|
|
2748
|
+
});
|
|
2749
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
2750
|
+
const section = cachedSection ?? soulSection(ctx.cwd, null);
|
|
2751
|
+
const base = event.systemPrompt;
|
|
2752
|
+
return { systemPrompt: base.length > 0 ? `${base}\n\n${section}` : section };
|
|
2753
|
+
});
|
|
2754
|
+
};
|
|
2755
|
+
//#endregion
|
|
2756
|
+
//#region src/extensions/subagent/index.ts
|
|
2757
|
+
const log$2 = logger.child({ module: "subagent-ext" });
|
|
2758
|
+
const MAX_TASKS = 8;
|
|
2759
|
+
const TaskItem = Type.Object({
|
|
2760
|
+
task: Type.String({ description: "The task to delegate to a subagent run." }),
|
|
2761
|
+
persona: Type.Optional(Type.String({ description: "Optional extra system prompt / role for this task, applied ON TOP of the child run's own default persona (your full identity and soul are still there underneath). Omit to run with just your default persona." })),
|
|
2762
|
+
model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on (e.g. \"anthropic/claude-opus-4-8\"). Must be a real catalogued model. Omit to run on your own model. If you are locked to a Google-compliant model, only compliant models are accepted." }))
|
|
2763
|
+
});
|
|
2764
|
+
const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
|
|
2765
|
+
description: "One or more tasks to delegate. Each spawns an isolated subagent run linked to this conversation; they run in parallel and each rewakes you with its result when it finishes.",
|
|
2766
|
+
minItems: 1,
|
|
2767
|
+
maxItems: MAX_TASKS
|
|
2768
|
+
}) });
|
|
2769
|
+
const SUBAGENT_TOOL_NAME = "subagent";
|
|
2770
|
+
function buildTool(messageId) {
|
|
2771
|
+
return {
|
|
2772
|
+
name: SUBAGENT_TOOL_NAME,
|
|
2773
|
+
label: "Subagent",
|
|
2774
|
+
description: [
|
|
2775
|
+
"Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
|
|
2776
|
+
"Use it to parallelize independent work, to keep a large or noisy subtask out of your own context, or to run a task under a specialized persona.",
|
|
2777
|
+
"Fire-and-forget: this returns immediately after queueing. It does NOT wait for results. Each subagent runs on its own and, when it finishes, sends you its result on this thread — so queue the work, then keep going or end your turn. To chain, re-delegate after a result lands.",
|
|
2778
|
+
"Pass tasks: [{ task, persona?, model? }]. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task; omit it to run on your own model."
|
|
2779
|
+
].join(" "),
|
|
2780
|
+
promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
|
|
2781
|
+
parameters: SubagentParams,
|
|
2782
|
+
async execute(_toolCallId, params) {
|
|
2783
|
+
const { tasks } = params;
|
|
2784
|
+
if (!messageId) return {
|
|
2785
|
+
content: [{
|
|
2786
|
+
type: "text",
|
|
2787
|
+
text: "Subagent delegation is unavailable in this context (no originating conversation to link the runs to)."
|
|
2788
|
+
}],
|
|
2789
|
+
details: {},
|
|
2790
|
+
isError: true
|
|
2791
|
+
};
|
|
2792
|
+
const spawnTasks = tasks.map((t) => ({
|
|
2793
|
+
task: t.task,
|
|
2794
|
+
persona: t.persona ?? null,
|
|
2795
|
+
model: t.model ?? null
|
|
2796
|
+
}));
|
|
2797
|
+
try {
|
|
2798
|
+
const { taskIds } = await postSubagentSpawn({
|
|
2799
|
+
messageId,
|
|
2800
|
+
tasks: spawnTasks
|
|
2801
|
+
});
|
|
2802
|
+
log$2.info({
|
|
2803
|
+
event: "subagent_spawned",
|
|
2804
|
+
count: taskIds.length
|
|
2805
|
+
}, "subagent tasks queued");
|
|
2806
|
+
const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.task ?? ""}`).join("\n");
|
|
2807
|
+
return {
|
|
2808
|
+
content: [{
|
|
2809
|
+
type: "text",
|
|
2810
|
+
text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}`
|
|
2811
|
+
}],
|
|
2812
|
+
details: { taskIds }
|
|
2813
|
+
};
|
|
2814
|
+
} catch (err) {
|
|
2815
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2816
|
+
log$2.warn({
|
|
2817
|
+
err,
|
|
2818
|
+
event: "subagent_spawn_failed"
|
|
2819
|
+
}, "subagent spawn failed");
|
|
2820
|
+
return {
|
|
2821
|
+
content: [{
|
|
2822
|
+
type: "text",
|
|
2823
|
+
text: `Failed to queue subagent runs: ${message}`
|
|
2824
|
+
}],
|
|
2825
|
+
details: {},
|
|
2826
|
+
isError: true
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2833
|
+
* Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
|
|
2834
|
+
* The factory takes the session's channel context to resolve the originating
|
|
2835
|
+
* messageId — the api links each spawned run to the conversation that message
|
|
2836
|
+
* belongs to and rewakes it on completion (nothing about the parent is piped
|
|
2837
|
+
* from the sandbox beyond that id).
|
|
2838
|
+
*/
|
|
2839
|
+
function createSubagentExtension({ channelContext }) {
|
|
2840
|
+
return (pi) => {
|
|
2841
|
+
const messageId = extractMessageId(channelContext);
|
|
2842
|
+
startFeatureFlagPoller();
|
|
2843
|
+
pi.on("session_start", async () => {
|
|
2844
|
+
if (getPolledFlag("subagent") === true) {
|
|
2845
|
+
pi.registerTool(buildTool(messageId));
|
|
2846
|
+
log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
|
|
2847
|
+
}
|
|
2848
|
+
});
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
//#endregion
|
|
2852
|
+
//#region src/extensions/tool-call-env.ts
|
|
2853
|
+
const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
|
|
2854
|
+
function withToolCallId({ command, toolCallId }) {
|
|
2855
|
+
return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
|
|
2856
|
+
}
|
|
2857
|
+
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
|
|
2858
|
+
function stripPlatformExportsForDisplay(command) {
|
|
2859
|
+
let c = command;
|
|
2860
|
+
let m;
|
|
2861
|
+
while (m = c.match(PLATFORM_EXPORT)) c = c.slice(m[0].length);
|
|
2862
|
+
return c;
|
|
2863
|
+
}
|
|
2864
|
+
const toolCallEnvExtension = (pi) => {
|
|
2865
|
+
pi.on("tool_call", async (event) => {
|
|
2866
|
+
if (event.toolName !== "bash") return;
|
|
2867
|
+
if (typeof event.input.command !== "string") return;
|
|
2868
|
+
event.input.command = withToolCallId({
|
|
2869
|
+
command: event.input.command,
|
|
2870
|
+
toolCallId: event.toolCallId
|
|
2871
|
+
});
|
|
2872
|
+
});
|
|
2873
|
+
};
|
|
2874
|
+
//#endregion
|
|
2875
|
+
//#region src/extensions/tool-call-summary.ts
|
|
2876
|
+
const log$1 = logger.child({ module: "tool-call-summary-extension" });
|
|
2877
|
+
/**
|
|
2878
|
+
* The injected parameter name: a namespaced sentinel, so it can never collide
|
|
2879
|
+
* with a real tool argument and is unmistakable in transcripts and logs. The
|
|
2880
|
+
* frontend renderer (ANY-2723) duplicates this literal — keep the two in sync.
|
|
2881
|
+
*/
|
|
2882
|
+
const TOOL_CALL_SUMMARY_FIELD = "__skydive_summary__";
|
|
2883
|
+
/** JSON Schema fragment for the injected parameter. */
|
|
2884
|
+
const SUMMARY_PROPERTY = {
|
|
2885
|
+
type: "string",
|
|
2886
|
+
description: "Required for every tool call. A concise, specific summary (max ~8 words) of what THIS call does and why, written for a person watching the conversation, e.g. \"Searching feedback for billing complaints\" or \"Reading the auth middleware\". Address the user directly in second person: the summary is read by the user, so refer to their things as \"your\", never in third person — \"Reading your emails\", not \"Reading his emails\". Always use the present progressive tense, since it is shown while the call runs: \"Updating your Slack\", never \"Updated your Slack\". Make each summary distinct from your other tool calls; never reuse a generic label like \"Search query\" or \"Running command\"."
|
|
2887
|
+
};
|
|
2888
|
+
const jsonSchemaObjectSchema = z.object({
|
|
2889
|
+
type: z.unknown().optional(),
|
|
2890
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
2891
|
+
required: z.array(z.string()).optional(),
|
|
2892
|
+
additionalProperties: z.unknown().optional()
|
|
2893
|
+
}).passthrough();
|
|
2894
|
+
const toolEntrySchema = z.object({
|
|
2895
|
+
name: z.string().optional(),
|
|
2896
|
+
input_schema: jsonSchemaObjectSchema.optional(),
|
|
2897
|
+
parameters: jsonSchemaObjectSchema.optional(),
|
|
2898
|
+
function: z.object({
|
|
2899
|
+
name: z.string().optional(),
|
|
2900
|
+
parameters: jsonSchemaObjectSchema.optional()
|
|
2901
|
+
}).passthrough().optional()
|
|
2902
|
+
}).passthrough();
|
|
2903
|
+
const payloadWithToolsSchema = z.object({ tools: z.array(z.unknown()) }).passthrough();
|
|
2904
|
+
/**
|
|
2905
|
+
* Add the summary property to one JSON Schema object. Returns the augmented
|
|
2906
|
+
* copy, or `null` when the tool should be left untouched: a strict schema
|
|
2907
|
+
* (`additionalProperties: false`) whose validation would reject the extra
|
|
2908
|
+
* field, or one that already declares a `__skydive_summary__` property of its own.
|
|
2909
|
+
*/
|
|
2910
|
+
function augmentSchema(schema) {
|
|
2911
|
+
if (schema.additionalProperties === false) return null;
|
|
2912
|
+
const properties = schema.properties ?? {};
|
|
2913
|
+
if ("__skydive_summary__" in properties) return null;
|
|
2914
|
+
const required = schema.required ?? [];
|
|
2915
|
+
return {
|
|
2916
|
+
...schema,
|
|
2917
|
+
type: schema.type ?? "object",
|
|
2918
|
+
properties: {
|
|
2919
|
+
[TOOL_CALL_SUMMARY_FIELD]: SUMMARY_PROPERTY,
|
|
2920
|
+
...properties
|
|
2921
|
+
},
|
|
2922
|
+
required: required.includes("__skydive_summary__") ? required : [...required, TOOL_CALL_SUMMARY_FIELD]
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
/**
|
|
2926
|
+
* Augment a single tool entry, dispatching on which provider shape it is.
|
|
2927
|
+
* Returns the (possibly rebuilt) entry and whether anything changed. Skipped
|
|
2928
|
+
* tools — wrong shape, strict, or name in `strictToolNames` — return unchanged.
|
|
2929
|
+
*/
|
|
2930
|
+
function augmentToolEntry(entry, strictToolNames) {
|
|
2931
|
+
const parsed = toolEntrySchema.safeParse(entry);
|
|
2932
|
+
if (!parsed.success) return {
|
|
2933
|
+
entry,
|
|
2934
|
+
changed: false
|
|
2935
|
+
};
|
|
2936
|
+
const tool = parsed.data;
|
|
2937
|
+
const name = tool.name ?? tool.function?.name ?? null;
|
|
2938
|
+
if (name !== null && strictToolNames.has(name)) return {
|
|
2939
|
+
entry,
|
|
2940
|
+
changed: false
|
|
2941
|
+
};
|
|
2942
|
+
if (tool.input_schema) {
|
|
2943
|
+
const augmented = augmentSchema(tool.input_schema);
|
|
2944
|
+
if (!augmented) return {
|
|
2945
|
+
entry,
|
|
2946
|
+
changed: false
|
|
2947
|
+
};
|
|
2948
|
+
return {
|
|
2949
|
+
entry: {
|
|
2950
|
+
...tool,
|
|
2951
|
+
input_schema: augmented
|
|
2952
|
+
},
|
|
2953
|
+
changed: true
|
|
2954
|
+
};
|
|
2955
|
+
}
|
|
2956
|
+
if (tool.parameters) {
|
|
2957
|
+
const augmented = augmentSchema(tool.parameters);
|
|
2958
|
+
if (!augmented) return {
|
|
2959
|
+
entry,
|
|
2960
|
+
changed: false
|
|
2961
|
+
};
|
|
2962
|
+
return {
|
|
2963
|
+
entry: {
|
|
2964
|
+
...tool,
|
|
2965
|
+
parameters: augmented
|
|
2966
|
+
},
|
|
2967
|
+
changed: true
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
if (tool.function?.parameters) {
|
|
2971
|
+
const augmented = augmentSchema(tool.function.parameters);
|
|
2972
|
+
if (!augmented) return {
|
|
2973
|
+
entry,
|
|
2974
|
+
changed: false
|
|
2975
|
+
};
|
|
2976
|
+
return {
|
|
2977
|
+
entry: {
|
|
2978
|
+
...tool,
|
|
2979
|
+
function: {
|
|
2980
|
+
...tool.function,
|
|
2981
|
+
parameters: augmented
|
|
2982
|
+
}
|
|
2983
|
+
},
|
|
2984
|
+
changed: true
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
return {
|
|
2988
|
+
entry,
|
|
2989
|
+
changed: false
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Inject the summary field into every eligible tool in a provider payload.
|
|
2994
|
+
* Returns a new payload when at least one tool was augmented, or `undefined`
|
|
2995
|
+
* to signal "no change" (which keeps the original payload, per the
|
|
2996
|
+
* `before_provider_request` contract).
|
|
2997
|
+
*
|
|
2998
|
+
* @param payload The outgoing provider payload (shape varies by provider).
|
|
2999
|
+
* @param strictToolNames Names of tools whose registered schema is strict and
|
|
3000
|
+
* must be skipped to avoid validation errors.
|
|
3001
|
+
*/
|
|
3002
|
+
function injectToolCallSummary(payload, strictToolNames) {
|
|
3003
|
+
const parsed = payloadWithToolsSchema.safeParse(payload);
|
|
3004
|
+
if (!parsed.success || parsed.data.tools.length === 0) return void 0;
|
|
3005
|
+
let changed = false;
|
|
3006
|
+
const tools = parsed.data.tools.map((entry) => {
|
|
3007
|
+
const result = augmentToolEntry(entry, strictToolNames);
|
|
3008
|
+
if (result.changed) changed = true;
|
|
3009
|
+
return result.entry;
|
|
3010
|
+
});
|
|
3011
|
+
if (!changed) return void 0;
|
|
3012
|
+
return {
|
|
3013
|
+
...parsed.data,
|
|
3014
|
+
tools
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* Names of registered tools whose schema sets `additionalProperties: false`.
|
|
3019
|
+
* Pi validates the model's tool args against this registered schema, so the
|
|
3020
|
+
* injected field would make a strict tool's call fail validation — skip them.
|
|
3021
|
+
*/
|
|
3022
|
+
function getStrictToolNames(pi) {
|
|
3023
|
+
const names = /* @__PURE__ */ new Set();
|
|
3024
|
+
for (const tool of pi.getAllTools()) {
|
|
3025
|
+
const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
|
|
3026
|
+
if (parsed.success && parsed.data.additionalProperties === false) names.add(tool.name);
|
|
3027
|
+
}
|
|
3028
|
+
return names;
|
|
3029
|
+
}
|
|
3030
|
+
function toolDeclaresSummaryParam(pi, toolName) {
|
|
3031
|
+
const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
|
|
3032
|
+
if (!tool) return false;
|
|
3033
|
+
const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
|
|
3034
|
+
return parsed.success && parsed.data.properties != null && "__skydive_summary__" in parsed.data.properties;
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* Remove the injected summary from a tool's execution input. No-op when the
|
|
3038
|
+
* field is absent, or when the tool genuinely declares a `__skydive_summary__`
|
|
3039
|
+
* parameter of its own (which we never inject into, so its value is real).
|
|
3040
|
+
* Mutates `input` in place, matching the `tool_call` contract.
|
|
3041
|
+
*
|
|
3042
|
+
* Fails open: this runs on the critical path of tool execution, and the
|
|
3043
|
+
* `getAllTools()` lookup can throw. On any error we leave `input` untouched
|
|
3044
|
+
* (the sentinel may pass through to the tool, but a bug here can never break
|
|
3045
|
+
* tool execution).
|
|
3046
|
+
*/
|
|
3047
|
+
function stripInjectedSummary(pi, toolName, input) {
|
|
3048
|
+
try {
|
|
3049
|
+
if (!("__skydive_summary__" in input)) return;
|
|
3050
|
+
if (toolDeclaresSummaryParam(pi, toolName)) return;
|
|
3051
|
+
delete input[TOOL_CALL_SUMMARY_FIELD];
|
|
3052
|
+
} catch (err) {
|
|
3053
|
+
log$1.error({
|
|
3054
|
+
err,
|
|
3055
|
+
event: "tool_call_summary_strip_failed",
|
|
3056
|
+
toolName
|
|
3057
|
+
}, "tool_call_summary strip failed; leaving tool input untouched");
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
3061
|
+
* Compute the rewritten payload for a `before_provider_request` event, failing
|
|
3062
|
+
* open: on any error the original payload is left untouched so a bug here can
|
|
3063
|
+
* never break an LLM call.
|
|
3064
|
+
*/
|
|
3065
|
+
function buildInjectedPayload(pi, payload) {
|
|
3066
|
+
try {
|
|
3067
|
+
return injectToolCallSummary(payload, getStrictToolNames(pi));
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
log$1.error({
|
|
3070
|
+
err,
|
|
3071
|
+
event: "tool_call_summary_injection_failed"
|
|
3072
|
+
}, "tool_call_summary injection failed; passing payload through unchanged");
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
const toolCallSummaryExtension = (pi) => {
|
|
3077
|
+
pi.on("before_provider_request", (event) => buildInjectedPayload(pi, event.payload));
|
|
3078
|
+
pi.on("tool_call", (event) => {
|
|
3079
|
+
stripInjectedSummary(pi, event.toolName, event.input);
|
|
3080
|
+
});
|
|
3081
|
+
};
|
|
3082
|
+
//#endregion
|
|
3083
|
+
//#region src/extensions/background-tasks.ts
|
|
3084
|
+
/**
|
|
3085
|
+
* Background bash tasks as a pi extension.
|
|
3086
|
+
*
|
|
3087
|
+
* Gives the agent Claude Code-style background execution so a long-running
|
|
3088
|
+
* command never blocks the turn loop (a blocked turn means steers queue and
|
|
3089
|
+
* the user sees silence — the 2026-06-11 incident shape):
|
|
3090
|
+
*
|
|
3091
|
+
* - `bg_run` launches the command in the background, streaming output to a log
|
|
3092
|
+
* file, and returns immediately with a task id.
|
|
3093
|
+
* - `bg_status` / `bg_logs` / `bg_kill` inspect (with a stall hint), tail, and
|
|
3094
|
+
* stop a task.
|
|
3095
|
+
* - On completion the agent is woken: mid-run via
|
|
3096
|
+
* `pi.sendMessage(..., { deliverAs: 'followUp', triggerTurn: true })`; if no
|
|
3097
|
+
* agent loop is active (the run already ended), the extension POSTs the result
|
|
3098
|
+
* to the api (`/sandbox/bg-task-done`), which resolves the conversation from
|
|
3099
|
+
* the task's origin messageId and spawns a fresh run carrying it (the same
|
|
3100
|
+
* system-control-run path auth-fulfill / MCP-OAuth use to wake an idle agent —
|
|
3101
|
+
* a new run, not a steer). A lost POST falls back to injecting at the next
|
|
3102
|
+
* session_start for that conversation.
|
|
3103
|
+
* - A process-wide watchdog keeps the sandbox alive while any task runs
|
|
3104
|
+
* (capped) and kills tasks whose log exceeds the size cap. A stall hint
|
|
3105
|
+
* (no output for a while) surfaces on demand in `bg_status`.
|
|
3106
|
+
*
|
|
3107
|
+
* **Execution is pi's own.** Every task runs through pi's
|
|
3108
|
+
* `createLocalBashOperations().exec` — the same backend the built-in bash tool
|
|
3109
|
+
* uses — so shell config (`bash -c`), the agent's cwd, `getShellEnv()`, the
|
|
3110
|
+
* cwd-exists guard, process-tree kill, and orphan tracking all match pi's
|
|
3111
|
+
* built-in bash, by construction. We hold the exec promise rather than awaiting
|
|
3112
|
+
* it inline: it resolves on completion (→ the wake), and `bg_kill` / the
|
|
3113
|
+
* watchdog abort its signal (→ `killProcessTree`).
|
|
3114
|
+
*
|
|
3115
|
+
* **State is in-memory, scoped to the conversation.** One harness process
|
|
3116
|
+
* serves all of an agent's conversations, so task state is held in a
|
|
3117
|
+
* module-level map tagged with the conversation that started it. The sandbox
|
|
3118
|
+
* has no DB, so each session resolves its conversation once — lazily, api-side
|
|
3119
|
+
* from the origin messageId in its channel context (`resolveConversationFromApi`)
|
|
3120
|
+
* — and every run of the same conversation resolves to the same id, keeping the
|
|
3121
|
+
* shared map correctly scoped across turns. `bg_*`, the completion wake, and the
|
|
3122
|
+
* next-session injection all filter to the resolved conversation — an agent
|
|
3123
|
+
* never sees or is woken by a task from a different chat. Only the output log
|
|
3124
|
+
* spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
|
|
3125
|
+
* in memory; exit code and run state live on the in-memory task.
|
|
3126
|
+
*
|
|
3127
|
+
* **No cross-restart survival (v1, deliberate).** Task state lives only in
|
|
3128
|
+
* the running harness process. A harness restart (crash → supervisord
|
|
3129
|
+
* respawn, or `platform harness reload` after the agent edits its own
|
|
3130
|
+
* harness) drops the map and pi's exec children are reaped with it. We don't
|
|
3131
|
+
* resurrect from disk because the common next-run case cold-provisions a
|
|
3132
|
+
* *different* sandbox anyway (warm reuse is the minority in prod), so on-disk
|
|
3133
|
+
* state would rarely be the box the next run lands on. The idle-completion wake
|
|
3134
|
+
* does cross the sandbox → platform boundary (a fresh run via `bg-task-done`),
|
|
3135
|
+
* but a task whose harness dies before it finishes is gone — it is not
|
|
3136
|
+
* resurrected, and this stays distinct from the scheduled-run (cron) system.
|
|
3137
|
+
*/
|
|
3138
|
+
const log = logger.child({ module: "background-tasks-ext" });
|
|
3139
|
+
const ops = createLocalBashOperations();
|
|
3140
|
+
function tasksDir() {
|
|
3141
|
+
return process.env.SKYDIVE_BG_TASKS_DIR ?? process.env.ANYONE_BG_TASKS_DIR ?? "/home/user/.anyone/bg-tasks";
|
|
3142
|
+
}
|
|
3143
|
+
const WATCHDOG_INTERVAL_MS = 3e4;
|
|
3144
|
+
const KEEPALIVE_EVERY_MS = 6e4;
|
|
3145
|
+
const KEEPALIVE_MAX_MS = 3600 * 1e3;
|
|
3146
|
+
const STALL_HINT_AFTER_MS = 120 * 1e3;
|
|
3147
|
+
const MAX_LOG_BYTES = 100 * 1024 * 1024;
|
|
3148
|
+
const DEFAULT_TAIL_LINES = 30;
|
|
3149
|
+
const TAIL_READ_BYTES = 64 * 1024;
|
|
3150
|
+
function taskLabel(meta) {
|
|
3151
|
+
return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
|
|
3152
|
+
}
|
|
3153
|
+
let taskCounter = 0;
|
|
3154
|
+
const tasks = /* @__PURE__ */ new Map();
|
|
3155
|
+
let watchdogInterval = null;
|
|
3156
|
+
let lastKeepaliveAt = 0;
|
|
3157
|
+
function sameConversation(meta, conversationId) {
|
|
3158
|
+
return meta.conversationId === conversationId;
|
|
3159
|
+
}
|
|
3160
|
+
function logPath(id) {
|
|
3161
|
+
return join(tasksDir(), `${id}.log`);
|
|
3162
|
+
}
|
|
3163
|
+
async function readLogChunk(id, maxBytes, anchor) {
|
|
3164
|
+
let fh = null;
|
|
3165
|
+
try {
|
|
3166
|
+
fh = await open(logPath(id), "r");
|
|
3167
|
+
const { size } = await fh.stat();
|
|
3168
|
+
const readBytes = Math.min(size, maxBytes);
|
|
3169
|
+
const offset = anchor === "tail" ? size - readBytes : 0;
|
|
3170
|
+
const buffer = Buffer.alloc(readBytes);
|
|
3171
|
+
await fh.read(buffer, 0, readBytes, offset);
|
|
3172
|
+
let start = 0;
|
|
3173
|
+
if (anchor === "tail" && readBytes < size) while (start < buffer.length && (buffer[start] & 192) === 128) start++;
|
|
3174
|
+
return {
|
|
3175
|
+
text: buffer.toString("utf8", start),
|
|
3176
|
+
size
|
|
3177
|
+
};
|
|
3178
|
+
} catch {
|
|
3179
|
+
return {
|
|
3180
|
+
text: "",
|
|
3181
|
+
size: 0
|
|
3182
|
+
};
|
|
3183
|
+
} finally {
|
|
3184
|
+
await fh?.close();
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
function readLogEnd(id, maxBytes) {
|
|
3188
|
+
return readLogChunk(id, maxBytes, "tail");
|
|
3189
|
+
}
|
|
3190
|
+
async function tailLog(id, lines) {
|
|
3191
|
+
const { text } = await readLogEnd(id, TAIL_READ_BYTES);
|
|
3192
|
+
const all = text.split("\n");
|
|
3193
|
+
const tail = all.slice(Math.max(0, all.length - lines - 1)).join("\n");
|
|
3194
|
+
return tail.trim().length > 0 ? tail : "(no output yet)";
|
|
3195
|
+
}
|
|
3196
|
+
async function headLog(id, lines) {
|
|
3197
|
+
const { text } = await readLogChunk(id, TAIL_READ_BYTES, "head");
|
|
3198
|
+
const head = text.split("\n").slice(0, lines).join("\n");
|
|
3199
|
+
return head.trim().length > 0 ? head : "(no output yet)";
|
|
3200
|
+
}
|
|
3201
|
+
function secondsSinceLastOutput(meta) {
|
|
3202
|
+
return Math.round((Date.now() - meta.lastOutputAt) / 1e3);
|
|
3203
|
+
}
|
|
3204
|
+
async function describeStatus(meta, lines) {
|
|
3205
|
+
const killNote = meta.killedReason ? ` (killed: ${meta.killedReason})` : "";
|
|
3206
|
+
if (!meta.running) {
|
|
3207
|
+
if (meta.exitCode !== null) return `Task ${meta.id} finished with exit code ${meta.exitCode}${killNote}.\nLast output:\n${await tailLog(meta.id, lines)}`;
|
|
3208
|
+
const why = meta.killedReason ? "" : meta.error ? ` (${meta.error})` : " (process ended without an exit code — likely killed or the sandbox restarted)";
|
|
3209
|
+
return `Task ${meta.id} ended${killNote}${why}.\nLast output:\n${await tailLog(meta.id, lines)}`;
|
|
3210
|
+
}
|
|
3211
|
+
const runningForS = Math.round((Date.now() - meta.startedAt) / 1e3);
|
|
3212
|
+
const quietS = secondsSinceLastOutput(meta);
|
|
3213
|
+
const stallHint = quietS * 1e3 > STALL_HINT_AFTER_MS ? `\n⚠ No output for ${quietS}s — the command may be stalled or waiting for interactive input it will never get.` : "";
|
|
3214
|
+
return `Task ${meta.id} is running (${runningForS}s elapsed).${stallHint}\nLast output:\n${await tailLog(meta.id, lines)}`;
|
|
3215
|
+
}
|
|
3216
|
+
function watchdogTick(now) {
|
|
3217
|
+
const running = [...tasks.values()].filter((t) => t.running);
|
|
3218
|
+
if (running.length === 0) {
|
|
3219
|
+
stopWatchdog();
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
for (const meta of running) {
|
|
3223
|
+
const overRuntime = now - meta.startedAt > KEEPALIVE_MAX_MS;
|
|
3224
|
+
const overSize = meta.logBytes > MAX_LOG_BYTES;
|
|
3225
|
+
if (!overRuntime && !overSize) continue;
|
|
3226
|
+
const reason = overSize ? `output exceeded ${MAX_LOG_BYTES / (1024 * 1024)}MiB` : `exceeded ${KEEPALIVE_MAX_MS / 6e4}m max runtime`;
|
|
3227
|
+
log.warn({
|
|
3228
|
+
taskId: meta.id,
|
|
3229
|
+
reason
|
|
3230
|
+
}, "watchdog killing bg task");
|
|
3231
|
+
try {
|
|
3232
|
+
killTask(meta, reason);
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
log.warn({
|
|
3235
|
+
err,
|
|
3236
|
+
taskId: meta.id
|
|
3237
|
+
}, "watchdog kill failed");
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
if (now - lastKeepaliveAt >= KEEPALIVE_EVERY_MS) {
|
|
3241
|
+
lastKeepaliveAt = now;
|
|
3242
|
+
postHeartbeat({ messageId: null });
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
function stopWatchdog() {
|
|
3246
|
+
if (watchdogInterval) {
|
|
3247
|
+
clearInterval(watchdogInterval);
|
|
3248
|
+
watchdogInterval = null;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
function ensureWatchdog() {
|
|
3252
|
+
if (watchdogInterval) return;
|
|
3253
|
+
watchdogInterval = setInterval(() => watchdogTick(Date.now()), WATCHDOG_INTERVAL_MS);
|
|
3254
|
+
}
|
|
3255
|
+
function killTask(meta, reason) {
|
|
3256
|
+
if (reason) meta.killedReason = reason;
|
|
3257
|
+
meta.controller.abort();
|
|
3258
|
+
}
|
|
3259
|
+
function createBackgroundTasksExtension({ channelContext }) {
|
|
3260
|
+
return (pi) => {
|
|
3261
|
+
const messageId = extractMessageId(channelContext);
|
|
3262
|
+
let conversationId = null;
|
|
3263
|
+
let conversationIdPromise = null;
|
|
3264
|
+
function ensureConversationId() {
|
|
3265
|
+
if (!messageId) return Promise.resolve(null);
|
|
3266
|
+
return conversationIdPromise ??= resolveConversationFromApi(messageId).then((id) => {
|
|
3267
|
+
conversationId = id;
|
|
3268
|
+
return id;
|
|
3269
|
+
});
|
|
3270
|
+
}
|
|
3271
|
+
let agentActive = false;
|
|
3272
|
+
pi.on("agent_start", async () => {
|
|
3273
|
+
agentActive = true;
|
|
3274
|
+
});
|
|
3275
|
+
pi.on("agent_end", async () => {
|
|
3276
|
+
agentActive = false;
|
|
3277
|
+
});
|
|
3278
|
+
async function taskDoneMessage(meta) {
|
|
3279
|
+
const code = meta.exitCode;
|
|
3280
|
+
const status = meta.killedReason ? "killed" : code === 0 ? "completed" : code === null ? "finished" : "failed";
|
|
3281
|
+
const codeSuffix = code !== null ? ` (exit code ${code})` : "";
|
|
3282
|
+
const killNote = meta.killedReason ? `\n<kill-reason>${meta.killedReason}</kill-reason>` : "";
|
|
3283
|
+
const recentOutput = await tailLog(meta.id, 20);
|
|
3284
|
+
return {
|
|
3285
|
+
customType: "anyone-bg-task-done",
|
|
3286
|
+
content: `<background-task-finished>
|
|
3287
|
+
<task-id>${meta.id}</task-id>
|
|
3288
|
+
<status>${status}</status>
|
|
3289
|
+
<exit-code>${code ?? "unknown"}</exit-code>
|
|
3290
|
+
<command>${meta.command}</command>
|
|
3291
|
+
<summary>Background task ${taskLabel(meta)} ${status}${codeSuffix}</summary>${killNote}
|
|
3292
|
+
<recent-output>
|
|
3293
|
+
${recentOutput}
|
|
3294
|
+
</recent-output>
|
|
3295
|
+
</background-task-finished>
|
|
3296
|
+
Run bg_logs for the full output.
|
|
3297
|
+
|
|
3298
|
+
This is a background-task completion, not a message from the user. If it needs no user-facing response — a routine or expected finish, a leftover or self-killed process, nothing the user must act on or would want to know right now — call \`platform channel suppress-reply\` and output nothing. Only send a message if the outcome changes what the user should do or know, or if you were explicitly waiting to report this result.`,
|
|
3299
|
+
display: false
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
async function notifyCompletion(meta) {
|
|
3303
|
+
if (meta.notified) return;
|
|
3304
|
+
if (agentActive && sameConversation(meta, conversationId)) {
|
|
3305
|
+
meta.notified = true;
|
|
3306
|
+
pi.sendMessage(await taskDoneMessage(meta), {
|
|
3307
|
+
triggerTurn: true,
|
|
3308
|
+
deliverAs: "followUp"
|
|
3309
|
+
});
|
|
3310
|
+
log.info({
|
|
3311
|
+
taskId: meta.id,
|
|
3312
|
+
conversationId
|
|
3313
|
+
}, "bg task completion delivered live (followUp)");
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3316
|
+
if (meta.messageId) {
|
|
3317
|
+
meta.notified = true;
|
|
3318
|
+
log.info({
|
|
3319
|
+
taskId: meta.id,
|
|
3320
|
+
messageId: meta.messageId
|
|
3321
|
+
}, "posting idle bg-task-done wake");
|
|
3322
|
+
const message = await taskDoneMessage(meta);
|
|
3323
|
+
postBackgroundTaskDone({
|
|
3324
|
+
messageId: meta.messageId,
|
|
3325
|
+
content: message.content
|
|
3326
|
+
}).catch((err) => {
|
|
3327
|
+
meta.notified = false;
|
|
3328
|
+
log.warn({
|
|
3329
|
+
err,
|
|
3330
|
+
taskId: meta.id
|
|
3331
|
+
}, "bg-task-done wake failed; will retry at next session_start");
|
|
3332
|
+
});
|
|
3333
|
+
} else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
|
|
3334
|
+
}
|
|
3335
|
+
async function launchTask({ command, description, cwd }) {
|
|
3336
|
+
taskCounter += 1;
|
|
3337
|
+
const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
|
|
3338
|
+
try {
|
|
3339
|
+
await mkdir(tasksDir(), { recursive: true });
|
|
3340
|
+
} catch (err) {
|
|
3341
|
+
log.error({
|
|
3342
|
+
err,
|
|
3343
|
+
taskId: id
|
|
3344
|
+
}, "background task dir unavailable");
|
|
3345
|
+
}
|
|
3346
|
+
const logStream = createWriteStream(logPath(id), { flags: "a" });
|
|
3347
|
+
logStream.on("error", (err) => {
|
|
3348
|
+
log.warn({
|
|
3349
|
+
err,
|
|
3350
|
+
taskId: id
|
|
3351
|
+
}, "bg task log write failed");
|
|
3352
|
+
});
|
|
3353
|
+
const startedAt = Date.now();
|
|
3354
|
+
const meta = {
|
|
3355
|
+
id,
|
|
3356
|
+
command: stripPlatformExportsForDisplay(command),
|
|
3357
|
+
startedAt,
|
|
3358
|
+
logBytes: 0,
|
|
3359
|
+
lastOutputAt: startedAt,
|
|
3360
|
+
conversationId,
|
|
3361
|
+
messageId,
|
|
3362
|
+
description,
|
|
3363
|
+
notified: false,
|
|
3364
|
+
killedReason: null,
|
|
3365
|
+
controller: new AbortController(),
|
|
3366
|
+
running: true,
|
|
3367
|
+
exitCode: null,
|
|
3368
|
+
error: null
|
|
3369
|
+
};
|
|
3370
|
+
tasks.set(id, meta);
|
|
3371
|
+
ops.exec(command, cwd, {
|
|
3372
|
+
onData: (chunk) => {
|
|
3373
|
+
meta.logBytes += chunk.length;
|
|
3374
|
+
meta.lastOutputAt = Date.now();
|
|
3375
|
+
logStream.write(chunk);
|
|
3376
|
+
},
|
|
3377
|
+
signal: meta.controller.signal
|
|
3378
|
+
}).then((r) => {
|
|
3379
|
+
meta.exitCode = r.exitCode;
|
|
3380
|
+
}).catch((err) => {
|
|
3381
|
+
if (!meta.controller.signal.aborted) {
|
|
3382
|
+
meta.error = err instanceof Error ? err.message : String(err);
|
|
3383
|
+
log.warn({
|
|
3384
|
+
err,
|
|
3385
|
+
taskId: id
|
|
3386
|
+
}, "bg task exec error");
|
|
3387
|
+
}
|
|
3388
|
+
}).finally(async () => {
|
|
3389
|
+
logStream.end();
|
|
3390
|
+
await finished(logStream).catch(() => {});
|
|
3391
|
+
meta.running = false;
|
|
3392
|
+
log.info({
|
|
3393
|
+
taskId: id,
|
|
3394
|
+
exitCode: meta.exitCode
|
|
3395
|
+
}, "bg task finished");
|
|
3396
|
+
await notifyCompletion(meta);
|
|
3397
|
+
});
|
|
3398
|
+
ensureWatchdog();
|
|
3399
|
+
log.info({
|
|
3400
|
+
taskId: id,
|
|
3401
|
+
conversationId
|
|
3402
|
+
}, "bg task started");
|
|
3403
|
+
return meta;
|
|
3404
|
+
}
|
|
3405
|
+
function knownTaskIds() {
|
|
3406
|
+
return [...tasks.values()].filter((t) => sameConversation(t, conversationId)).map((t) => t.id).join(", ") || "(none)";
|
|
3407
|
+
}
|
|
3408
|
+
pi.on("session_start", async () => {
|
|
3409
|
+
if (tasks.size === 0) return;
|
|
3410
|
+
await ensureConversationId();
|
|
3411
|
+
for (const [id, meta] of tasks) if (sameConversation(meta, conversationId) && !meta.running && meta.notified) {
|
|
3412
|
+
tasks.delete(id);
|
|
3413
|
+
await unlink(logPath(id)).catch(() => {});
|
|
3414
|
+
}
|
|
3415
|
+
const unnotified = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && !t.notified && !t.running);
|
|
3416
|
+
for (const meta of unnotified) {
|
|
3417
|
+
meta.notified = true;
|
|
3418
|
+
pi.sendMessage(await taskDoneMessage(meta));
|
|
3419
|
+
}
|
|
3420
|
+
if (unnotified.length > 0) log.info({
|
|
3421
|
+
conversationId,
|
|
3422
|
+
count: unnotified.length
|
|
3423
|
+
}, "injected completed bg tasks at session_start");
|
|
3424
|
+
if ([...tasks.values()].some((t) => sameConversation(t, conversationId) && t.running)) ensureWatchdog();
|
|
3425
|
+
});
|
|
3426
|
+
function err(text) {
|
|
3427
|
+
return {
|
|
3428
|
+
error: {
|
|
3429
|
+
content: [{
|
|
3430
|
+
type: "text",
|
|
3431
|
+
text
|
|
3432
|
+
}],
|
|
3433
|
+
details: {},
|
|
3434
|
+
isError: true
|
|
3435
|
+
},
|
|
3436
|
+
meta: null
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
function resolveTask(taskId) {
|
|
3440
|
+
const exact = tasks.get(taskId);
|
|
3441
|
+
if (exact && sameConversation(exact, conversationId)) return {
|
|
3442
|
+
error: null,
|
|
3443
|
+
meta: exact
|
|
3444
|
+
};
|
|
3445
|
+
const matches = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && t.id.startsWith(taskId));
|
|
3446
|
+
if (matches.length === 1) return {
|
|
3447
|
+
error: null,
|
|
3448
|
+
meta: matches[0]
|
|
3449
|
+
};
|
|
3450
|
+
if (matches.length > 1) return err(`Ambiguous task prefix "${taskId}" matches: ${matches.map((t) => t.id).join(", ")}`);
|
|
3451
|
+
return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
|
|
3452
|
+
}
|
|
3453
|
+
function listTasks() {
|
|
3454
|
+
const mine = [...tasks.values()].filter((t) => sameConversation(t, conversationId));
|
|
3455
|
+
if (mine.length === 0) return "No background tasks.";
|
|
3456
|
+
return mine.map((t) => {
|
|
3457
|
+
const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
|
|
3458
|
+
const elapsed = Math.round((Date.now() - t.startedAt) / 1e3);
|
|
3459
|
+
const cmd = t.description ? ` — ${t.command.slice(0, 60)}` : "";
|
|
3460
|
+
return `${taskLabel(t)} — ${state}, ${elapsed}s${cmd}`;
|
|
3461
|
+
}).join("\n");
|
|
3462
|
+
}
|
|
3463
|
+
const taskIdParam = Type.Object({ taskId: Type.String({ description: "Task id from bg_run" }) });
|
|
3464
|
+
const bgRun = {
|
|
3465
|
+
name: "bg_run",
|
|
3466
|
+
label: "Run in background",
|
|
3467
|
+
description: "Run a bash command in the background. Returns immediately with a task id; output streams to a log file. You are sent a message when it finishes — keep working or end your turn meanwhile. Use for anything over a couple of minutes (builds, batch jobs, retry loops, downloads). Inspect with bg_status / bg_logs, stop with bg_kill.",
|
|
3468
|
+
promptSnippet: "bg_run — run a long command without blocking; you are notified on completion",
|
|
3469
|
+
parameters: Type.Object({
|
|
3470
|
+
command: Type.String({ description: "Bash command to execute" }),
|
|
3471
|
+
description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." }))
|
|
3472
|
+
}),
|
|
3473
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3474
|
+
await ensureConversationId();
|
|
3475
|
+
const { command, description = null } = params;
|
|
3476
|
+
const meta = await launchTask({
|
|
3477
|
+
command,
|
|
3478
|
+
description,
|
|
3479
|
+
cwd: ctx.cwd
|
|
3480
|
+
});
|
|
3481
|
+
return {
|
|
3482
|
+
content: [{
|
|
3483
|
+
type: "text",
|
|
3484
|
+
text: `Started background task ${taskLabel(meta)}.\nlog: ${logPath(meta.id)}\nYou will get a message when it finishes. Check on it with bg_status {"taskId":"${meta.id}"}.`
|
|
3485
|
+
}],
|
|
3486
|
+
details: {}
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
};
|
|
3490
|
+
const bgStatus = {
|
|
3491
|
+
name: "bg_status",
|
|
3492
|
+
label: "Background task status",
|
|
3493
|
+
description: "Report whether a background task is running or finished, with its exit code and recent output (and a stall warning if it has gone quiet).",
|
|
3494
|
+
parameters: Type.Object({
|
|
3495
|
+
taskId: Type.Optional(Type.String({ description: "Task id or unambiguous prefix. Omit to list all your tasks." })),
|
|
3496
|
+
lines: Type.Optional(Type.Number({ description: "Lines of recent output to include" }))
|
|
3497
|
+
}),
|
|
3498
|
+
async execute(_toolCallId, params) {
|
|
3499
|
+
await ensureConversationId();
|
|
3500
|
+
const { taskId, lines = DEFAULT_TAIL_LINES } = params;
|
|
3501
|
+
if (!taskId) return {
|
|
3502
|
+
content: [{
|
|
3503
|
+
type: "text",
|
|
3504
|
+
text: listTasks()
|
|
3505
|
+
}],
|
|
3506
|
+
details: {}
|
|
3507
|
+
};
|
|
3508
|
+
const resolved = resolveTask(taskId);
|
|
3509
|
+
if (resolved.error) return resolved.error;
|
|
3510
|
+
return {
|
|
3511
|
+
content: [{
|
|
3512
|
+
type: "text",
|
|
3513
|
+
text: await describeStatus(resolved.meta, lines)
|
|
3514
|
+
}],
|
|
3515
|
+
details: {}
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
};
|
|
3519
|
+
const bgLogs = {
|
|
3520
|
+
name: "bg_logs",
|
|
3521
|
+
label: "Background task logs",
|
|
3522
|
+
description: "Show a background task's output log — the last lines by default, or the first lines with tail=false.",
|
|
3523
|
+
parameters: Type.Object({
|
|
3524
|
+
taskId: Type.String({ description: "Task id or unambiguous prefix" }),
|
|
3525
|
+
lines: Type.Optional(Type.Number({ description: "Number of lines" })),
|
|
3526
|
+
tail: Type.Optional(Type.Boolean({ description: "Last lines (default) or first when false" }))
|
|
3527
|
+
}),
|
|
3528
|
+
async execute(_toolCallId, params) {
|
|
3529
|
+
await ensureConversationId();
|
|
3530
|
+
const { taskId, lines = DEFAULT_TAIL_LINES, tail = true } = params;
|
|
3531
|
+
const resolved = resolveTask(taskId);
|
|
3532
|
+
if (resolved.error) return resolved.error;
|
|
3533
|
+
return {
|
|
3534
|
+
content: [{
|
|
3535
|
+
type: "text",
|
|
3536
|
+
text: tail ? await tailLog(resolved.meta.id, lines) : await headLog(resolved.meta.id, lines)
|
|
3537
|
+
}],
|
|
3538
|
+
details: {}
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
};
|
|
3542
|
+
const bgKill = {
|
|
3543
|
+
name: "bg_kill",
|
|
3544
|
+
label: "Kill background task",
|
|
3545
|
+
description: "Terminate a running background task and its whole process group.",
|
|
3546
|
+
parameters: taskIdParam,
|
|
3547
|
+
async execute(_toolCallId, params) {
|
|
3548
|
+
await ensureConversationId();
|
|
3549
|
+
const { taskId } = params;
|
|
3550
|
+
const resolved = resolveTask(taskId);
|
|
3551
|
+
if (resolved.error) return resolved.error;
|
|
3552
|
+
const meta = resolved.meta;
|
|
3553
|
+
if (!meta.running) return {
|
|
3554
|
+
content: [{
|
|
3555
|
+
type: "text",
|
|
3556
|
+
text: `Task ${taskId} is not running.\n${await describeStatus(meta, 10)}`
|
|
3557
|
+
}],
|
|
3558
|
+
details: {}
|
|
3559
|
+
};
|
|
3560
|
+
try {
|
|
3561
|
+
killTask(meta, null);
|
|
3562
|
+
meta.notified = true;
|
|
3563
|
+
return {
|
|
3564
|
+
content: [{
|
|
3565
|
+
type: "text",
|
|
3566
|
+
text: `Killed task ${taskId}.`
|
|
3567
|
+
}],
|
|
3568
|
+
details: {}
|
|
3569
|
+
};
|
|
3570
|
+
} catch (killErr) {
|
|
3571
|
+
return {
|
|
3572
|
+
content: [{
|
|
3573
|
+
type: "text",
|
|
3574
|
+
text: `Failed to kill task ${taskId}: ${killErr instanceof Error ? killErr.message : String(killErr)}`
|
|
3575
|
+
}],
|
|
3576
|
+
details: {},
|
|
3577
|
+
isError: true
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
};
|
|
3582
|
+
pi.registerTool(bgRun);
|
|
3583
|
+
pi.registerTool(bgStatus);
|
|
3584
|
+
pi.registerTool(bgLogs);
|
|
3585
|
+
pi.registerTool(bgKill);
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
//#endregion
|
|
3589
|
+
//#region src/extensions/index.ts
|
|
3590
|
+
/**
|
|
3591
|
+
* The static (config-free) agent extensions, in load order. `memoryExtension`
|
|
3592
|
+
* is the generic, agent-owned slice (`projects/`/`feedback/`/`reference/`); the
|
|
3593
|
+
* per-user `users/` slice is rendered by the platform memory extension inside
|
|
3594
|
+
* `platformExtensions()`, which can resolve the current user.
|
|
3595
|
+
*/
|
|
3596
|
+
const all = [
|
|
3597
|
+
currentTimeExtension,
|
|
3598
|
+
soulExtension,
|
|
3599
|
+
memoryExtension,
|
|
3600
|
+
mcp_default,
|
|
3601
|
+
localToolsExtension,
|
|
3602
|
+
toolCallEnvExtension,
|
|
3603
|
+
bashDefaultTimeoutExtension,
|
|
3604
|
+
toolCallSummaryExtension
|
|
3605
|
+
];
|
|
3606
|
+
/**
|
|
3607
|
+
* Platform-owned extensions — daemon session tracking, sandbox heartbeats,
|
|
3608
|
+
* OTel self-tracing, and background tasks. These are platform capabilities
|
|
3609
|
+
* every session must include (not agent-authored content); the session
|
|
3610
|
+
* factory appends them to the agent-chosen set.
|
|
3611
|
+
*/
|
|
3612
|
+
function platformExtensions({ sessionId, channelContext }) {
|
|
3613
|
+
return [
|
|
3614
|
+
createPlatformExtensions({
|
|
3615
|
+
sessionId,
|
|
3616
|
+
channelContext
|
|
3617
|
+
}),
|
|
3618
|
+
createPlatformMemoryExtension({ channelContext }),
|
|
3619
|
+
selfTraceExtension,
|
|
3620
|
+
createBackgroundTasksExtension({ channelContext }),
|
|
3621
|
+
createSubagentExtension({ channelContext }),
|
|
3622
|
+
createContextManagementExtension()
|
|
3623
|
+
];
|
|
3624
|
+
}
|
|
3625
|
+
//#endregion
|
|
3626
|
+
export { all, createHarness, createHealthHandler, createPlatformEnvMiddleware, installToolUpdateAutoStop, isPlatformConfigLoaded, loadPlatformConfig, localToolsExtension, mcp_default as mcpExtension, memoryExtension, platformExtensions, runToolUpdateLoop, soulExtension, toolCallEnvExtension };
|