@skydiveai/pi-extensions 0.1.0-beta.26 → 0.1.0-beta.2601
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/README.md +13 -0
- package/dist/index.d.mts +56 -3
- package/dist/index.mjs +2493 -926
- package/package.json +3 -9
package/dist/index.mjs
CHANGED
|
@@ -2,25 +2,30 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
|
|
3
3
|
import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
|
|
4
4
|
import { buildAgentCard, chainMiddleware, composeHandlers, createAgentExecutor, createProtocolHandlers, getCurrentTraceparent, logger, mountAt, requestHeaders, requestUrl, webHandlerToMiddleware } from "@skydiveai/pi-server";
|
|
5
|
-
import { mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
-
import { z } from "zod";
|
|
8
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { z } from "zod";
|
|
9
9
|
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
11
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
12
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
13
13
|
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
14
14
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
15
15
|
import { Check, Errors } from "typebox/value";
|
|
16
|
+
import { hc } from "hono/client";
|
|
16
17
|
import { ROOT_CONTEXT, SpanStatusCode, propagation, trace } from "@opentelemetry/api";
|
|
17
18
|
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
18
19
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
19
20
|
import { Resource } from "@opentelemetry/resources";
|
|
20
21
|
import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
21
22
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
22
|
-
import {
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { availableParallelism } from "node:os";
|
|
25
|
+
import { promisify } from "node:util";
|
|
23
26
|
import { parse } from "yaml";
|
|
27
|
+
import { getModels } from "@earendil-works/pi-ai";
|
|
28
|
+
import { quote } from "shell-quote";
|
|
24
29
|
import { createWriteStream } from "node:fs";
|
|
25
30
|
import { finished } from "node:stream/promises";
|
|
26
31
|
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
@@ -237,167 +242,6 @@ function createHealthHandler({ metadata }) {
|
|
|
237
242
|
};
|
|
238
243
|
}
|
|
239
244
|
//#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
245
|
//#region src/extensions/capability-soul-nudge.ts
|
|
402
246
|
/**
|
|
403
247
|
* One-line reminder appended to a tool-update continuation when the agent
|
|
@@ -414,87 +258,356 @@ function installIterationCap({ session, log }, configOverride = null) {
|
|
|
414
258
|
*/
|
|
415
259
|
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
260
|
//#endregion
|
|
417
|
-
//#region src/extensions/
|
|
261
|
+
//#region src/extensions/tool-call-summary.ts
|
|
262
|
+
const log$15 = logger.child({ module: "tool-call-summary-extension" });
|
|
418
263
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
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.
|
|
264
|
+
* The injected parameter name: a namespaced sentinel, so it can never collide
|
|
265
|
+
* with a real tool argument and is unmistakable in transcripts and logs. The
|
|
266
|
+
* frontend renderer (ANY-2723) duplicates this literal — keep the two in sync.
|
|
439
267
|
*/
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
268
|
+
const TOOL_CALL_SUMMARY_FIELD = "__skydive_summary__";
|
|
269
|
+
/** JSON Schema fragment for the injected parameter. */
|
|
270
|
+
const SUMMARY_PROPERTY = {
|
|
271
|
+
type: "string",
|
|
272
|
+
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\"."
|
|
273
|
+
};
|
|
274
|
+
const jsonSchemaObjectSchema = z.object({
|
|
275
|
+
type: z.unknown().optional(),
|
|
276
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
277
|
+
required: z.array(z.string()).optional(),
|
|
278
|
+
additionalProperties: z.unknown().optional()
|
|
279
|
+
}).passthrough();
|
|
280
|
+
const toolEntrySchema = z.object({
|
|
281
|
+
name: z.string().optional(),
|
|
282
|
+
input_schema: jsonSchemaObjectSchema.optional(),
|
|
283
|
+
parameters: jsonSchemaObjectSchema.optional(),
|
|
284
|
+
function: z.object({
|
|
285
|
+
name: z.string().optional(),
|
|
286
|
+
parameters: jsonSchemaObjectSchema.optional()
|
|
287
|
+
}).passthrough().optional()
|
|
288
|
+
}).passthrough();
|
|
289
|
+
const payloadWithToolsSchema = z.object({ tools: z.array(z.unknown()) }).passthrough();
|
|
449
290
|
/**
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
* `
|
|
291
|
+
* Add the summary property to one JSON Schema object. Returns the augmented
|
|
292
|
+
* copy, or `null` when the tool should be left untouched: a strict schema
|
|
293
|
+
* (`additionalProperties: false`) whose validation would reject the extra
|
|
294
|
+
* field, or one that already declares a `__skydive_summary__` property of its own.
|
|
454
295
|
*/
|
|
455
|
-
function
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return lines.join("\n");
|
|
470
|
-
}
|
|
471
|
-
function isToolDefinition(x) {
|
|
472
|
-
return !!x && typeof x === "object" && typeof x.name === "string" && typeof x.execute === "function";
|
|
296
|
+
function augmentSchema(schema) {
|
|
297
|
+
if (schema.additionalProperties === false) return null;
|
|
298
|
+
const properties = schema.properties ?? {};
|
|
299
|
+
if ("__skydive_summary__" in properties) return null;
|
|
300
|
+
const required = schema.required ?? [];
|
|
301
|
+
return {
|
|
302
|
+
...schema,
|
|
303
|
+
type: schema.type ?? "object",
|
|
304
|
+
properties: {
|
|
305
|
+
[TOOL_CALL_SUMMARY_FIELD]: SUMMARY_PROPERTY,
|
|
306
|
+
...properties
|
|
307
|
+
},
|
|
308
|
+
required: required.includes("__skydive_summary__") ? required : [...required, TOOL_CALL_SUMMARY_FIELD]
|
|
309
|
+
};
|
|
473
310
|
}
|
|
474
311
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
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.
|
|
312
|
+
* Augment a single tool entry, dispatching on which provider shape it is.
|
|
313
|
+
* Returns the (possibly rebuilt) entry and whether anything changed. Skipped
|
|
314
|
+
* tools — wrong shape, strict, or name in `strictToolNames` — return unchanged.
|
|
481
315
|
*/
|
|
482
|
-
function
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
...tool,
|
|
488
|
-
promptSnippet: fallback
|
|
316
|
+
function augmentToolEntry(entry, strictToolNames) {
|
|
317
|
+
const parsed = toolEntrySchema.safeParse(entry);
|
|
318
|
+
if (!parsed.success) return {
|
|
319
|
+
entry,
|
|
320
|
+
changed: false
|
|
489
321
|
};
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
322
|
+
const tool = parsed.data;
|
|
323
|
+
const name = tool.name ?? tool.function?.name ?? null;
|
|
324
|
+
if (name !== null && strictToolNames.has(name)) return {
|
|
325
|
+
entry,
|
|
326
|
+
changed: false
|
|
327
|
+
};
|
|
328
|
+
if (tool.input_schema) {
|
|
329
|
+
const augmented = augmentSchema(tool.input_schema);
|
|
330
|
+
if (!augmented) return {
|
|
331
|
+
entry,
|
|
332
|
+
changed: false
|
|
333
|
+
};
|
|
334
|
+
return {
|
|
335
|
+
entry: {
|
|
336
|
+
...tool,
|
|
337
|
+
input_schema: augmented
|
|
338
|
+
},
|
|
339
|
+
changed: true
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (tool.parameters) {
|
|
343
|
+
const augmented = augmentSchema(tool.parameters);
|
|
344
|
+
if (!augmented) return {
|
|
345
|
+
entry,
|
|
346
|
+
changed: false
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
entry: {
|
|
350
|
+
...tool,
|
|
351
|
+
parameters: augmented
|
|
352
|
+
},
|
|
353
|
+
changed: true
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (tool.function?.parameters) {
|
|
357
|
+
const augmented = augmentSchema(tool.function.parameters);
|
|
358
|
+
if (!augmented) return {
|
|
359
|
+
entry,
|
|
360
|
+
changed: false
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
entry: {
|
|
364
|
+
...tool,
|
|
365
|
+
function: {
|
|
366
|
+
...tool.function,
|
|
367
|
+
parameters: augmented
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
changed: true
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
entry,
|
|
375
|
+
changed: false
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Inject the summary field into every eligible tool in a provider payload.
|
|
380
|
+
* Returns a new payload when at least one tool was augmented, or `undefined`
|
|
381
|
+
* to signal "no change" (which keeps the original payload, per the
|
|
382
|
+
* `before_provider_request` contract).
|
|
383
|
+
*
|
|
384
|
+
* @param payload The outgoing provider payload (shape varies by provider).
|
|
385
|
+
* @param strictToolNames Names of tools whose registered schema is strict and
|
|
386
|
+
* must be skipped to avoid validation errors.
|
|
387
|
+
*/
|
|
388
|
+
function injectToolCallSummary(payload, strictToolNames) {
|
|
389
|
+
const parsed = payloadWithToolsSchema.safeParse(payload);
|
|
390
|
+
if (!parsed.success || parsed.data.tools.length === 0) return void 0;
|
|
391
|
+
let changed = false;
|
|
392
|
+
const tools = parsed.data.tools.map((entry) => {
|
|
393
|
+
const result = augmentToolEntry(entry, strictToolNames);
|
|
394
|
+
if (result.changed) changed = true;
|
|
395
|
+
return result.entry;
|
|
396
|
+
});
|
|
397
|
+
if (!changed) return void 0;
|
|
398
|
+
return {
|
|
399
|
+
...parsed.data,
|
|
400
|
+
tools
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Names of registered tools whose schema sets `additionalProperties: false`.
|
|
405
|
+
* Pi validates the model's tool args against this registered schema, so the
|
|
406
|
+
* injected field would make a strict tool's call fail validation — skip them.
|
|
407
|
+
*/
|
|
408
|
+
function getStrictToolNames(pi) {
|
|
409
|
+
const names = /* @__PURE__ */ new Set();
|
|
410
|
+
for (const tool of pi.getAllTools()) {
|
|
411
|
+
const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
|
|
412
|
+
if (parsed.success && parsed.data.additionalProperties === false) names.add(tool.name);
|
|
413
|
+
}
|
|
414
|
+
return names;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Whether a tool's own schema declares a `__skydive_summary__` property. We
|
|
418
|
+
* never inject into such a tool, so any value it carries is a real argument
|
|
419
|
+
* and must be left alone.
|
|
420
|
+
*/
|
|
421
|
+
function schemaDeclaresSummary(parameters) {
|
|
422
|
+
const parsed = jsonSchemaObjectSchema.safeParse(parameters);
|
|
423
|
+
return parsed.success && parsed.data.properties != null && "__skydive_summary__" in parsed.data.properties;
|
|
424
|
+
}
|
|
425
|
+
function toolDeclaresSummaryParam(pi, toolName) {
|
|
426
|
+
const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
|
|
427
|
+
if (!tool) return false;
|
|
428
|
+
return schemaDeclaresSummary(tool.parameters);
|
|
429
|
+
}
|
|
430
|
+
/** A copy of `args` without the injected summary. Never mutates its input. */
|
|
431
|
+
function withoutInjectedSummary(args) {
|
|
432
|
+
if (args == null || typeof args !== "object" || Array.isArray(args)) return args;
|
|
433
|
+
if (!("__skydive_summary__" in args)) return args;
|
|
434
|
+
const { [TOOL_CALL_SUMMARY_FIELD]: _summary, ...rest } = args;
|
|
435
|
+
return rest;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Register a tool so the injected summary is removed *before* pi validates
|
|
439
|
+
* the model's arguments against the tool's schema.
|
|
440
|
+
*
|
|
441
|
+
* Needed because the `tool_call` strip below runs too late. pi-agent-core's
|
|
442
|
+
* `prepareToolCall` goes: `tool.prepareArguments` → `validateToolArguments` →
|
|
443
|
+
* `beforeToolCall` (which is what dispatches `tool_call`). A tool whose schema
|
|
444
|
+
* sets `additionalProperties: false` therefore rejects the summary and errors
|
|
445
|
+
* the call — client-side, before any request reaches the server — while the
|
|
446
|
+
* strip meant to prevent exactly that sits one step further down.
|
|
447
|
+
*
|
|
448
|
+
* `augmentSchema` already skips strict tools, but that only controls what the
|
|
449
|
+
* model is *told*. It still emits the field on those tools, because every
|
|
450
|
+
* other tool in the list declares it as required for every call. So a server
|
|
451
|
+
* can connect, bind its tools, present a healthy inventory, and have every
|
|
452
|
+
* call fail — and it reads as the server's fault when it is ours.
|
|
453
|
+
*
|
|
454
|
+
* Apply to tools registered from schemas we do not author: MCP servers and
|
|
455
|
+
* local `tools/*.ts`. Tools built here with `Type.Object(...)` do not need it
|
|
456
|
+
* (TypeBox emits no `additionalProperties`, so the field validates fine and
|
|
457
|
+
* the `tool_call` strip removes it in time).
|
|
458
|
+
*
|
|
459
|
+
* Fails open, like the rest of this module: if the strip throws, the original
|
|
460
|
+
* arguments are used rather than failing the call.
|
|
461
|
+
*/
|
|
462
|
+
function withSummaryStrippedBeforeValidation(tool) {
|
|
463
|
+
if (schemaDeclaresSummary(tool.parameters)) return tool;
|
|
464
|
+
const toolPrepare = tool.prepareArguments;
|
|
465
|
+
const prepareArguments = ((args) => {
|
|
466
|
+
let stripped = args;
|
|
467
|
+
try {
|
|
468
|
+
stripped = withoutInjectedSummary(args);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
log$15.error({
|
|
471
|
+
err,
|
|
472
|
+
event: "tool_call_summary_prepare_strip_failed",
|
|
473
|
+
toolName: tool.name
|
|
474
|
+
}, "tool_call_summary pre-validation strip failed; leaving arguments untouched");
|
|
475
|
+
}
|
|
476
|
+
return toolPrepare ? toolPrepare(stripped) : stripped;
|
|
477
|
+
});
|
|
478
|
+
return {
|
|
479
|
+
...tool,
|
|
480
|
+
prepareArguments
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Remove the injected summary from a tool's execution input. No-op when the
|
|
485
|
+
* field is absent, or when the tool genuinely declares a `__skydive_summary__`
|
|
486
|
+
* parameter of its own (which we never inject into, so its value is real).
|
|
487
|
+
* Mutates `input` in place, matching the `tool_call` contract.
|
|
488
|
+
*
|
|
489
|
+
* Fails open: this runs on the critical path of tool execution, and the
|
|
490
|
+
* `getAllTools()` lookup can throw. On any error we leave `input` untouched
|
|
491
|
+
* (the sentinel may pass through to the tool, but a bug here can never break
|
|
492
|
+
* tool execution).
|
|
493
|
+
*/
|
|
494
|
+
function stripInjectedSummary(pi, toolName, input) {
|
|
495
|
+
try {
|
|
496
|
+
if (!("__skydive_summary__" in input)) return;
|
|
497
|
+
if (toolDeclaresSummaryParam(pi, toolName)) return;
|
|
498
|
+
delete input[TOOL_CALL_SUMMARY_FIELD];
|
|
499
|
+
} catch (err) {
|
|
500
|
+
log$15.error({
|
|
501
|
+
err,
|
|
502
|
+
event: "tool_call_summary_strip_failed",
|
|
503
|
+
toolName
|
|
504
|
+
}, "tool_call_summary strip failed; leaving tool input untouched");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Compute the rewritten payload for a `before_provider_request` event, failing
|
|
509
|
+
* open: on any error the original payload is left untouched so a bug here can
|
|
510
|
+
* never break an LLM call.
|
|
511
|
+
*/
|
|
512
|
+
function buildInjectedPayload(pi, payload) {
|
|
513
|
+
try {
|
|
514
|
+
return injectToolCallSummary(payload, getStrictToolNames(pi));
|
|
515
|
+
} catch (err) {
|
|
516
|
+
log$15.error({
|
|
517
|
+
err,
|
|
518
|
+
event: "tool_call_summary_injection_failed"
|
|
519
|
+
}, "tool_call_summary injection failed; passing payload through unchanged");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const toolCallSummaryExtension = (pi) => {
|
|
524
|
+
pi.on("before_provider_request", (event) => buildInjectedPayload(pi, event.payload));
|
|
525
|
+
pi.on("tool_call", (event) => {
|
|
526
|
+
stripInjectedSummary(pi, event.toolName, event.input);
|
|
527
|
+
});
|
|
528
|
+
};
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/extensions/local-tools.ts
|
|
531
|
+
/**
|
|
532
|
+
* Local-tools adapter as a pi extension. Mirrors the mcp.ts hot-reload pattern
|
|
533
|
+
* one level over: walks `tools/` (relative to the harness cwd), imports each
|
|
534
|
+
* module, and registers the exported `ToolDefinition`s with pi.
|
|
535
|
+
*
|
|
536
|
+
* The agent edits files under `tools/` between turns; on every `tool_result`
|
|
537
|
+
* the extension stat()s the directory's children, compares mtimes against
|
|
538
|
+
* what we last reconciled against, and queues a `pendingLocalToolsUpdate`
|
|
539
|
+
* if anything was added/removed/changed. The chat handler drains that queue
|
|
540
|
+
* after the current `session.prompt(...)` returns, calls `session.reload()`,
|
|
541
|
+
* and injects a synthetic continuation message — same dance as MCP.
|
|
542
|
+
*
|
|
543
|
+
* **ESM cache-busting.** `import(url)` in Node's ESM loader keys cached
|
|
544
|
+
* modules by URL. Re-importing the same path after editing the file gets
|
|
545
|
+
* the *original* module back. To force a fresh load on mtime change we
|
|
546
|
+
* append `?v=<mtimeMs>` to the URL — different URL, fresh module
|
|
547
|
+
* evaluation. The old version stays in memory but is unreachable.
|
|
548
|
+
*
|
|
549
|
+
* Each `.ts`/`.mjs`/`.js` file's default export should be a `ToolDefinition`
|
|
550
|
+
* or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
|
|
551
|
+
* `tools/_example.ts` documents the shape without registering.
|
|
552
|
+
*/
|
|
553
|
+
const log$14 = logger.child({ module: "local-tools-extension" });
|
|
554
|
+
const TOOLS_DIRNAME = "tools";
|
|
555
|
+
const fileState = /* @__PURE__ */ new Map();
|
|
556
|
+
let pendingLocalToolsUpdate = null;
|
|
557
|
+
function consumePendingLocalToolsUpdate() {
|
|
558
|
+
const update = pendingLocalToolsUpdate;
|
|
559
|
+
pendingLocalToolsUpdate = null;
|
|
560
|
+
return update;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Non-consuming peek used by the agent loop's `shouldStopAfterTurn` hook to
|
|
564
|
+
* decide whether to end the current `prompt()` after the in-flight turn so a
|
|
565
|
+
* fresh tool snapshot can be taken. The drain still happens in postPrompt via
|
|
566
|
+
* `consumePendingLocalToolsUpdate`.
|
|
567
|
+
*/
|
|
568
|
+
function hasPendingLocalToolsUpdate() {
|
|
569
|
+
return pendingLocalToolsUpdate !== null;
|
|
570
|
+
}
|
|
571
|
+
function formatLocalToolsUpdateMessage(summary) {
|
|
572
|
+
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."];
|
|
573
|
+
if (summary.added.length > 0) lines.push(`Newly loaded tool files: ${summary.added.join(", ")}`);
|
|
574
|
+
if (summary.refreshed.length > 0) lines.push(`Refreshed tool files: ${summary.refreshed.join(", ")}`);
|
|
575
|
+
if (summary.removed.length > 0) lines.push(`Removed tool files (and their tools): ${summary.removed.join(", ")}`);
|
|
576
|
+
if (summary.errors.length > 0) {
|
|
577
|
+
lines.push("Errors:");
|
|
578
|
+
for (const e of summary.errors) lines.push(` - ${e.file}: ${e.message}`);
|
|
579
|
+
}
|
|
580
|
+
if (summary.added.length > 0) lines.push(CAPABILITY_SOUL_NUDGE);
|
|
581
|
+
lines.push("Continue from where you left off, using the current tool list. Do not re-do work that already succeeded last turn.");
|
|
582
|
+
return lines.join("\n");
|
|
583
|
+
}
|
|
584
|
+
function isToolDefinition(x) {
|
|
585
|
+
return !!x && typeof x === "object" && typeof x.name === "string" && typeof x.execute === "function";
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Pi's system-prompt builder filters its visible-tool list to entries with a
|
|
589
|
+
* non-empty `promptSnippet` (system-prompt.js:49) — a tool with only a
|
|
590
|
+
* `description` won't appear in the system prompt's "available tools"
|
|
591
|
+
* section, which biases the LLM against using it. If the local tool author
|
|
592
|
+
* didn't set a snippet, default to the tool's description so the tool stays
|
|
593
|
+
* visible.
|
|
594
|
+
*/
|
|
595
|
+
function withDefaultPromptSnippet(tool) {
|
|
596
|
+
if (typeof tool.promptSnippet === "string" && tool.promptSnippet.trim()) return tool;
|
|
597
|
+
const fallback = tool.description?.trim();
|
|
598
|
+
if (!fallback) return tool;
|
|
599
|
+
return {
|
|
600
|
+
...tool,
|
|
601
|
+
promptSnippet: fallback
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
async function listToolFiles(dir) {
|
|
605
|
+
let entries;
|
|
606
|
+
try {
|
|
607
|
+
entries = await readdir(dir);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
if (err?.code === "ENOENT") return [];
|
|
610
|
+
throw err;
|
|
498
611
|
}
|
|
499
612
|
const out = [];
|
|
500
613
|
for (const file of entries) {
|
|
@@ -564,7 +677,7 @@ async function reconcileLocalTools({ pi, dir }) {
|
|
|
564
677
|
action = existing ? "refreshed" : "added";
|
|
565
678
|
}
|
|
566
679
|
for (const tool of tools) {
|
|
567
|
-
pi.registerTool(withDefaultPromptSnippet(tool));
|
|
680
|
+
pi.registerTool(withSummaryStrippedBeforeValidation(withDefaultPromptSnippet(tool)));
|
|
568
681
|
summary.totalTools++;
|
|
569
682
|
}
|
|
570
683
|
if (action === "added") summary.added.push(file);
|
|
@@ -581,7 +694,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
|
|
|
581
694
|
dir
|
|
582
695
|
});
|
|
583
696
|
if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
|
|
584
|
-
log$
|
|
697
|
+
log$14.info({
|
|
585
698
|
event: "local_tools_reconcile",
|
|
586
699
|
reason,
|
|
587
700
|
total_tools: summary.totalTools,
|
|
@@ -603,7 +716,7 @@ const localToolsExtension = (pi) => {
|
|
|
603
716
|
reason: "session_start"
|
|
604
717
|
});
|
|
605
718
|
} catch (err) {
|
|
606
|
-
log$
|
|
719
|
+
log$14.error({
|
|
607
720
|
err,
|
|
608
721
|
event: "local_tools_reconcile_failed"
|
|
609
722
|
}, "local tools reconcile failed");
|
|
@@ -615,7 +728,7 @@ const localToolsExtension = (pi) => {
|
|
|
615
728
|
try {
|
|
616
729
|
current = await listToolFiles(dir);
|
|
617
730
|
} catch (err) {
|
|
618
|
-
log$
|
|
731
|
+
log$14.warn({
|
|
619
732
|
err,
|
|
620
733
|
event: "local_tools_listing_failed"
|
|
621
734
|
}, "tools/ listing failed");
|
|
@@ -637,7 +750,7 @@ const localToolsExtension = (pi) => {
|
|
|
637
750
|
reason: "auto_reload"
|
|
638
751
|
});
|
|
639
752
|
} catch (err) {
|
|
640
|
-
log$
|
|
753
|
+
log$14.error({
|
|
641
754
|
err,
|
|
642
755
|
event: "local_tools_auto_reload_failed"
|
|
643
756
|
}, "auto-reload after tools/ change failed");
|
|
@@ -681,16 +794,101 @@ function createStderrBuffer({ maxBytes }) {
|
|
|
681
794
|
* The transport stays alive — caller holds the client so the bridge's
|
|
682
795
|
* callback server keeps listening and a later reconcile re-probes.
|
|
683
796
|
*/
|
|
797
|
+
var SkipOutputSchemaValidation = class {
|
|
798
|
+
getValidator(_schema) {
|
|
799
|
+
return (input) => ({
|
|
800
|
+
valid: true,
|
|
801
|
+
data: input,
|
|
802
|
+
errorMessage: void 0
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
const skipOutputSchemaValidation = new SkipOutputSchemaValidation();
|
|
684
807
|
const DEFAULT_CONNECT_TIMEOUT_MS = 5e3;
|
|
685
808
|
const STDERR_BUFFER_BYTES = 4096;
|
|
809
|
+
const SESSION_NOT_FOUND_RETRY_DELAYS_MS = [
|
|
810
|
+
250,
|
|
811
|
+
500,
|
|
812
|
+
1e3,
|
|
813
|
+
2e3,
|
|
814
|
+
4e3,
|
|
815
|
+
8e3
|
|
816
|
+
];
|
|
817
|
+
function delay(ms) {
|
|
818
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
819
|
+
}
|
|
820
|
+
async function fetchWithSessionNotFoundRetry(input, init) {
|
|
821
|
+
for (let attempt = 0;; attempt += 1) {
|
|
822
|
+
const response = await fetch(input, init);
|
|
823
|
+
const sessionId = new Headers(init?.headers).get("mcp-session-id");
|
|
824
|
+
const retryDelay = SESSION_NOT_FOUND_RETRY_DELAYS_MS[attempt];
|
|
825
|
+
if (!sessionId || response.status !== 404 || retryDelay === void 0 || !(await response.clone().text()).includes("session_not_found")) return response;
|
|
826
|
+
await response.body?.cancel();
|
|
827
|
+
await delay(retryDelay);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
686
830
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
831
|
+
* Sandbox egress variables a stdio MCP server needs to make outbound HTTPS
|
|
832
|
+
* calls. The SDK's default child environment is a tiny whitelist (HOME,
|
|
833
|
+
* LOGNAME, PATH, SHELL, TERM, USER on Linux), which drops NODE_EXTRA_CA_CERTS
|
|
834
|
+
* — but the sandbox's egress proxy terminates TLS with a private CA, so a
|
|
835
|
+
* child spawned without it fails every HTTPS request with a bare
|
|
836
|
+
* `fetch failed` (cause: UNABLE_TO_VERIFY_LEAF_SIGNATURE) that looks like the
|
|
837
|
+
* remote service is down. Same story for SSL_CERT_FILE (OpenSSL-based
|
|
838
|
+
* runtimes) and the *_PROXY set. Inherit them from this process so a stdio
|
|
839
|
+
* server gets working egress like every other process in the sandbox.
|
|
693
840
|
*/
|
|
841
|
+
const INHERITED_EGRESS_ENV_VARS = [
|
|
842
|
+
"NODE_EXTRA_CA_CERTS",
|
|
843
|
+
"SSL_CERT_FILE",
|
|
844
|
+
"SSL_CERT_DIR",
|
|
845
|
+
"REQUESTS_CA_BUNDLE",
|
|
846
|
+
"CURL_CA_BUNDLE",
|
|
847
|
+
"HTTP_PROXY",
|
|
848
|
+
"HTTPS_PROXY",
|
|
849
|
+
"NO_PROXY",
|
|
850
|
+
"http_proxy",
|
|
851
|
+
"https_proxy",
|
|
852
|
+
"no_proxy"
|
|
853
|
+
];
|
|
854
|
+
/**
|
|
855
|
+
* Environment for a stdio MCP server child: the SDK's safe defaults, plus the
|
|
856
|
+
* sandbox's egress/TLS variables, plus (last, so it wins) the server's own
|
|
857
|
+
* configured `env`. Building the merge here — instead of only when `env` is
|
|
858
|
+
* unset — also fixes the workaround trap where supplying any `env` in
|
|
859
|
+
* mcp.config.json silently replaced the ENTIRE default set, so a config that
|
|
860
|
+
* added one API key lost PATH/HOME and the server failed to spawn at all.
|
|
861
|
+
*/
|
|
862
|
+
function buildStdioEnv(configured, processEnv = process.env) {
|
|
863
|
+
const env = { ...getDefaultEnvironment() };
|
|
864
|
+
for (const key of INHERITED_EGRESS_ENV_VARS) {
|
|
865
|
+
const value = processEnv[key];
|
|
866
|
+
if (value !== void 0) env[key] = value;
|
|
867
|
+
}
|
|
868
|
+
if (configured) Object.assign(env, configured);
|
|
869
|
+
return env;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* undici's fetch throws `TypeError: fetch failed` with the actual
|
|
873
|
+
* network error hung off `.cause` (e.g. `getaddrinfo ENOTFOUND ...`,
|
|
874
|
+
* `ECONNREFUSED`, TLS errors). Surfacing only `err.message` makes
|
|
875
|
+
* every network failure read as a bare "fetch failed", which is
|
|
876
|
+
* indistinguishable from any other transport problem. Walk the cause
|
|
877
|
+
* chain so the agent sees the real underlying error.
|
|
878
|
+
*/
|
|
879
|
+
/**
|
|
880
|
+
* True when an error from an http MCP transport (connect, listTools, or a tool
|
|
881
|
+
* call) is an authentication failure. With no authProvider configured the SDK
|
|
882
|
+
* surfaces a 401 as `StreamableHTTPError(401)`; older paths translate it to
|
|
883
|
+
* `UnauthorizedError`. A dead/expired OAuth token (the proxy can no longer
|
|
884
|
+
* mint one) shows up here on the NEXT request against a previously-connected
|
|
885
|
+
* client — not just at connect — so reconcile must re-classify such a failure
|
|
886
|
+
* as `pending_auth` instead of a generic `failed`, keeping the "waiting on
|
|
887
|
+
* auth" report consistent with `platform auth`.
|
|
888
|
+
*/
|
|
889
|
+
function isUnauthorizedError(err) {
|
|
890
|
+
return err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401;
|
|
891
|
+
}
|
|
694
892
|
function formatError(err) {
|
|
695
893
|
if (!(err instanceof Error)) return String(err);
|
|
696
894
|
const parts = [err.message];
|
|
@@ -702,8 +900,20 @@ function formatError(err) {
|
|
|
702
900
|
}
|
|
703
901
|
return parts.join(": ");
|
|
704
902
|
}
|
|
903
|
+
function probeAlive(pid) {
|
|
904
|
+
if (pid === null) return null;
|
|
905
|
+
try {
|
|
906
|
+
process.kill(pid, 0);
|
|
907
|
+
return true;
|
|
908
|
+
} catch {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
705
912
|
async function connectHttp(_id, config, client) {
|
|
706
|
-
const transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
913
|
+
const transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
914
|
+
...config.headers !== null ? { requestInit: { headers: config.headers } } : {},
|
|
915
|
+
fetch: fetchWithSessionNotFoundRetry
|
|
916
|
+
});
|
|
707
917
|
try {
|
|
708
918
|
await client.connect(transport);
|
|
709
919
|
return {
|
|
@@ -712,12 +922,12 @@ async function connectHttp(_id, config, client) {
|
|
|
712
922
|
stderr: null
|
|
713
923
|
};
|
|
714
924
|
} catch (err) {
|
|
715
|
-
if (err
|
|
925
|
+
if (isUnauthorizedError(err)) return {
|
|
716
926
|
status: "pending_auth",
|
|
717
927
|
client,
|
|
718
928
|
stderr: "",
|
|
719
929
|
stderrBuffer: null,
|
|
720
|
-
cliHint: `platform auth mcp ${config.url}`
|
|
930
|
+
cliHint: `platform auth mcp ${config.url} --service "<Product>"`
|
|
721
931
|
};
|
|
722
932
|
return {
|
|
723
933
|
status: "failed",
|
|
@@ -731,14 +941,17 @@ async function connectClient(id, config, opts = {}) {
|
|
|
731
941
|
const client = new Client({
|
|
732
942
|
name: "skydive-harness",
|
|
733
943
|
version: "0.1.0"
|
|
734
|
-
}, {
|
|
944
|
+
}, {
|
|
945
|
+
capabilities: {},
|
|
946
|
+
jsonSchemaValidator: skipOutputSchemaValidation
|
|
947
|
+
});
|
|
735
948
|
if (config.transport === "http") return connectHttp(id, config, client);
|
|
736
949
|
const params = {
|
|
737
950
|
command: config.command,
|
|
738
951
|
args: config.args,
|
|
739
|
-
stderr: "pipe"
|
|
952
|
+
stderr: "pipe",
|
|
953
|
+
env: buildStdioEnv(config.env)
|
|
740
954
|
};
|
|
741
|
-
if (config.env !== null) params.env = config.env;
|
|
742
955
|
if (config.cwd !== null) params.cwd = config.cwd;
|
|
743
956
|
const transport = new StdioClientTransport(params);
|
|
744
957
|
const stderrBuffer = createStderrBuffer({ maxBytes: STDERR_BUFFER_BYTES });
|
|
@@ -750,6 +963,10 @@ async function connectClient(id, config, opts = {}) {
|
|
|
750
963
|
resolve();
|
|
751
964
|
};
|
|
752
965
|
});
|
|
966
|
+
let stdoutMessages = 0;
|
|
967
|
+
transport.onmessage = () => {
|
|
968
|
+
if (stdoutMessages < 1e4) stdoutMessages += 1;
|
|
969
|
+
};
|
|
753
970
|
const connectPromise = client.connect(transport);
|
|
754
971
|
const TIMEOUT_SENTINEL = Symbol("timeout");
|
|
755
972
|
const result = await Promise.race([
|
|
@@ -767,12 +984,20 @@ async function connectClient(id, config, opts = {}) {
|
|
|
767
984
|
error: `server "${id}" exited before completing initialize`,
|
|
768
985
|
stderr: stderrBuffer.read()
|
|
769
986
|
};
|
|
770
|
-
if (result === TIMEOUT_SENTINEL)
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
987
|
+
if (result === TIMEOUT_SENTINEL) {
|
|
988
|
+
const pid = transport.pid;
|
|
989
|
+
return {
|
|
990
|
+
status: "timeout",
|
|
991
|
+
client,
|
|
992
|
+
stderr: stderrBuffer.read(),
|
|
993
|
+
stderrBuffer,
|
|
994
|
+
diagnostics: {
|
|
995
|
+
pid,
|
|
996
|
+
alive: probeAlive(pid),
|
|
997
|
+
stdoutMessages
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
776
1001
|
try {
|
|
777
1002
|
await client.close();
|
|
778
1003
|
} catch {}
|
|
@@ -783,6 +1008,126 @@ async function connectClient(id, config, opts = {}) {
|
|
|
783
1008
|
};
|
|
784
1009
|
}
|
|
785
1010
|
//#endregion
|
|
1011
|
+
//#region src/extensions/mcp/limits.ts
|
|
1012
|
+
/**
|
|
1013
|
+
* Tool-budget guardrails for MCP registration.
|
|
1014
|
+
*
|
|
1015
|
+
* Why this exists: the agent loop sends the *full* tool list — every tool's
|
|
1016
|
+
* name, description, and JSON-schema `parameters` — to the model on every
|
|
1017
|
+
* prompt. MCP servers add tools without bound: a handful of chatty servers (or
|
|
1018
|
+
* one server that exposes 100+ tools, or a few tools with enormous schemas) can
|
|
1019
|
+
* push the registered set past what the model can accept, and the request is
|
|
1020
|
+
* rejected before the turn even runs. Because `reconcile` re-registers from
|
|
1021
|
+
* `mcp.config.json` on *every* turn, an over-limit config bricks the harness on
|
|
1022
|
+
* a loop — the agent can't get a turn to run in order to edit the config back
|
|
1023
|
+
* down. Worse, the person can't tell *why*: tools just stop working.
|
|
1024
|
+
*
|
|
1025
|
+
* What actually overflows the request is *tokens*, not tool count — a few tools
|
|
1026
|
+
* with deeply-nested schemas and long descriptions cost more than a hundred
|
|
1027
|
+
* trivial ones. And how many tokens are safe depends on the *model*: a 200K
|
|
1028
|
+
* context window can afford far more tool surface than a 32K one. So the primary
|
|
1029
|
+
* limiter is a **token budget derived from the active model's context window**,
|
|
1030
|
+
* with a fixed tool-count cap as a coarse secondary guard (and the fallback
|
|
1031
|
+
* when the model — hence its window — isn't known at reconcile time).
|
|
1032
|
+
*
|
|
1033
|
+
* The fix is to make registration bounded and fail-soft. We register in
|
|
1034
|
+
* deterministic config order and stop before we blow the budget, recording how
|
|
1035
|
+
* much we dropped so the agent is *told* it hit the limit and which servers
|
|
1036
|
+
* were truncated. A config that would have bricked the harness now degrades to
|
|
1037
|
+
* "a bounded set of tools plus a loud warning", which the agent can act on by
|
|
1038
|
+
* pruning servers.
|
|
1039
|
+
*
|
|
1040
|
+
* Everything is env-overridable so the ceilings can be tuned per deployment
|
|
1041
|
+
* without a release, but ships with conservative defaults. A cap value of 0 (or
|
|
1042
|
+
* a non-finite / negative override) disables that cap — an explicit escape
|
|
1043
|
+
* hatch, not the default.
|
|
1044
|
+
*/
|
|
1045
|
+
const env = process.env;
|
|
1046
|
+
/**
|
|
1047
|
+
* Fraction of the model's context window we're willing to spend on MCP tool
|
|
1048
|
+
* schemas. Tool definitions are sent on every prompt, so they permanently eat
|
|
1049
|
+
* into the window available for the conversation, but a generous tool budget is
|
|
1050
|
+
* worth more than a marginally larger conversation window given how the harness
|
|
1051
|
+
* is used. 0.35 of a 200K window is ~70K tokens of tool schema, comfortably
|
|
1052
|
+
* more than any sane MCP setup; of a 32K window it's ~11.2K, which still forces
|
|
1053
|
+
* truncation before a small model chokes.
|
|
1054
|
+
*/
|
|
1055
|
+
const DEFAULT_MCP_TOOL_TOKEN_BUDGET_FRACTION = .35;
|
|
1056
|
+
/**
|
|
1057
|
+
* Floor for the token budget when the model's context window is unknown at
|
|
1058
|
+
* reconcile time (e.g. the model hasn't been resolved yet). Generous enough not
|
|
1059
|
+
* to truncate an ordinary tool set, low enough to still catch a runaway.
|
|
1060
|
+
*/
|
|
1061
|
+
const DEFAULT_MCP_TOOL_TOKEN_BUDGET_FLOOR = 16e3;
|
|
1062
|
+
/**
|
|
1063
|
+
* Read a positive-integer cap from an env var, falling back to `fallback`.
|
|
1064
|
+
* A `0` override (or any non-finite / negative value) means "no cap" and is
|
|
1065
|
+
* returned as `Infinity`, so callers can compare against it directly.
|
|
1066
|
+
*/
|
|
1067
|
+
function readCap(raw, fallback) {
|
|
1068
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
1069
|
+
const parsed = Number(raw);
|
|
1070
|
+
if (!Number.isFinite(parsed) || parsed < 0) return Number.POSITIVE_INFINITY;
|
|
1071
|
+
if (parsed === 0) return Number.POSITIVE_INFINITY;
|
|
1072
|
+
return Math.floor(parsed);
|
|
1073
|
+
}
|
|
1074
|
+
/** Read a fraction in (0, 1] from an env var, falling back to `fallback`. */
|
|
1075
|
+
function readFraction(raw, fallback) {
|
|
1076
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
1077
|
+
const parsed = Number(raw);
|
|
1078
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) return fallback;
|
|
1079
|
+
return parsed;
|
|
1080
|
+
}
|
|
1081
|
+
/** Read a non-negative integer from an env var, falling back to `fallback`. */
|
|
1082
|
+
function readNonNegativeInt(raw, fallback) {
|
|
1083
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
1084
|
+
const parsed = Number(raw);
|
|
1085
|
+
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
|
1086
|
+
return Math.floor(parsed);
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Resolve the active caps from the environment. Read once per reconcile so a
|
|
1090
|
+
* deployment can retune without a restart, cheap enough not to cache.
|
|
1091
|
+
*/
|
|
1092
|
+
function resolveMcpToolLimits() {
|
|
1093
|
+
return {
|
|
1094
|
+
maxTotalTools: readCap(env["SKYDIVE_MCP_MAX_TOTAL_TOOLS"], 128),
|
|
1095
|
+
maxToolsPerServer: readCap(env["SKYDIVE_MCP_MAX_TOOLS_PER_SERVER"], 50),
|
|
1096
|
+
tokenBudgetFraction: readFraction(env["SKYDIVE_MCP_TOOL_TOKEN_BUDGET_FRACTION"], DEFAULT_MCP_TOOL_TOKEN_BUDGET_FRACTION),
|
|
1097
|
+
tokenBudgetFloor: readNonNegativeInt(env["SKYDIVE_MCP_TOOL_TOKEN_BUDGET_FLOOR"], DEFAULT_MCP_TOOL_TOKEN_BUDGET_FLOOR)
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* The token budget for MCP tool schemas, given the active model's context
|
|
1102
|
+
* window (or undefined when the model isn't known yet).
|
|
1103
|
+
*
|
|
1104
|
+
* With a known window we spend `tokenBudgetFraction` of it, but never less than
|
|
1105
|
+
* the floor — a tiny window shouldn't collapse the budget to near-zero and
|
|
1106
|
+
* strand every tool. With no window we fall back to the floor outright.
|
|
1107
|
+
*/
|
|
1108
|
+
function resolveTokenBudget(limits, contextWindow) {
|
|
1109
|
+
if (contextWindow === void 0 || !Number.isFinite(contextWindow)) return limits.tokenBudgetFloor;
|
|
1110
|
+
return Math.max(limits.tokenBudgetFloor, Math.floor(contextWindow * limits.tokenBudgetFraction));
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Estimate the tokens an MCP tool's *definition* costs in the request. We
|
|
1114
|
+
* serialize what's actually sent to the model — the tool name, its description,
|
|
1115
|
+
* and its JSON-schema parameters — and apply pi's own ~chars/4 heuristic
|
|
1116
|
+
* (`estimateTokens` in the coding agent uses the same convention; there is no
|
|
1117
|
+
* per-provider tokenizer to lean on, and the provider's real usage is only
|
|
1118
|
+
* known after the response). Rough by design, but it tracks the true cost far
|
|
1119
|
+
* better than a flat per-tool count: a fat schema is charged for its fatness.
|
|
1120
|
+
*/
|
|
1121
|
+
function estimateToolTokens(tool) {
|
|
1122
|
+
let chars = tool.name.length + (tool.description?.length ?? 0);
|
|
1123
|
+
if (tool.inputSchema !== void 0 && tool.inputSchema !== null) try {
|
|
1124
|
+
chars += JSON.stringify(tool.inputSchema).length;
|
|
1125
|
+
} catch {
|
|
1126
|
+
chars += 256;
|
|
1127
|
+
}
|
|
1128
|
+
return Math.ceil(chars / 4);
|
|
1129
|
+
}
|
|
1130
|
+
//#endregion
|
|
786
1131
|
//#region src/extensions/mcp/mcp-config.ts
|
|
787
1132
|
/**
|
|
788
1133
|
* mcp.config.json schema + loader. Split out from the extension so it has
|
|
@@ -862,7 +1207,7 @@ async function loadMcpConfig(path) {
|
|
|
862
1207
|
* Clients are keyed by JSON-stringified config and reused across
|
|
863
1208
|
* reloads — only changed configs reconnect.
|
|
864
1209
|
*/
|
|
865
|
-
const log$
|
|
1210
|
+
const log$13 = logger.child({ module: "mcp-extension" });
|
|
866
1211
|
async function closeConnected(connected) {
|
|
867
1212
|
try {
|
|
868
1213
|
await connected.client.close();
|
|
@@ -932,6 +1277,57 @@ function mcpResultToPiContent(result) {
|
|
|
932
1277
|
text: "MCP tool returned no content."
|
|
933
1278
|
}];
|
|
934
1279
|
}
|
|
1280
|
+
const MAX_ENUM_VALUES_LISTED = 12;
|
|
1281
|
+
function formatEnumValues(values) {
|
|
1282
|
+
const shown = values.slice(0, MAX_ENUM_VALUES_LISTED).map((v) => JSON.stringify(v));
|
|
1283
|
+
if (values.length > MAX_ENUM_VALUES_LISTED) shown.push(`… (${values.length} total)`);
|
|
1284
|
+
return shown.join(", ");
|
|
1285
|
+
}
|
|
1286
|
+
function extractEnum(propSchema) {
|
|
1287
|
+
if (Array.isArray(propSchema.enum) && propSchema.enum.length > 0) return propSchema.enum;
|
|
1288
|
+
const items = propSchema.items;
|
|
1289
|
+
if (items !== null && typeof items === "object" && Array.isArray(items.enum) && items.enum.length > 0) return items.enum;
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Build a compact, human-readable constraint hint from an MCP tool's raw JSON
|
|
1294
|
+
* schema so the model sees required fields and each param's allowed enum values
|
|
1295
|
+
* inline in the tool description — the schema pass-through hands pi the server's
|
|
1296
|
+
* schema verbatim, but the model attends to the prose far more than the raw
|
|
1297
|
+
* `enum`/`required` keys. Returns '' when there is nothing worth surfacing.
|
|
1298
|
+
*/
|
|
1299
|
+
function describeToolConstraints(inputSchema) {
|
|
1300
|
+
if (inputSchema === null || typeof inputSchema !== "object") return "";
|
|
1301
|
+
const schema = inputSchema;
|
|
1302
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : [];
|
|
1303
|
+
const properties = schema.properties !== null && typeof schema.properties === "object" ? schema.properties : {};
|
|
1304
|
+
const enumLines = [];
|
|
1305
|
+
for (const [propName, rawProp] of Object.entries(properties)) {
|
|
1306
|
+
if (rawProp === null || typeof rawProp !== "object") continue;
|
|
1307
|
+
const values = extractEnum(rawProp);
|
|
1308
|
+
if (values) enumLines.push(`- \`${propName}\` must be one of: ${formatEnumValues(values)}`);
|
|
1309
|
+
}
|
|
1310
|
+
if (required.length === 0 && enumLines.length === 0) return "";
|
|
1311
|
+
const lines = ["Argument constraints:"];
|
|
1312
|
+
if (required.length > 0) lines.push(`- required: ${required.map((r) => `\`${r}\``).join(", ")}`);
|
|
1313
|
+
lines.push(...enumLines);
|
|
1314
|
+
return lines.join("\n");
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Build the `promptSnippet` a registered MCP tool exposes to the model: the
|
|
1318
|
+
* server's description with the argument-constraint hint appended, falling back
|
|
1319
|
+
* to a labelled placeholder when the server omitted a description AND the schema
|
|
1320
|
+
* carries no constraints (pi's system-prompt builder hides tools whose snippet
|
|
1321
|
+
* is empty — system-prompt.js:49). This is the layer where `describeToolConstraints`
|
|
1322
|
+
* actually reaches the model, so it is exported and tested directly: the pure
|
|
1323
|
+
* hint being correct is necessary but not sufficient; what the model sees is
|
|
1324
|
+
* this composed snippet.
|
|
1325
|
+
*/
|
|
1326
|
+
function buildToolPromptSnippet({ description, inputSchema, serverId }) {
|
|
1327
|
+
const constraintHint = describeToolConstraints(inputSchema);
|
|
1328
|
+
const enriched = constraintHint.length > 0 ? `${description}${description.length > 0 ? "\n\n" : ""}${constraintHint}` : description;
|
|
1329
|
+
return enriched.length > 0 ? enriched : `MCP tool from server "${serverId}".`;
|
|
1330
|
+
}
|
|
935
1331
|
var McpExtension = class {
|
|
936
1332
|
mcpClients = /* @__PURE__ */ new Map();
|
|
937
1333
|
registeredMcpToolNames = /* @__PURE__ */ new Set();
|
|
@@ -951,8 +1347,12 @@ var McpExtension = class {
|
|
|
951
1347
|
const name = makeToolName(serverId, tool.name);
|
|
952
1348
|
const parameters = Type.Unsafe(tool.inputSchema);
|
|
953
1349
|
const description = tool.description?.trim() ?? "";
|
|
954
|
-
const promptSnippet =
|
|
955
|
-
|
|
1350
|
+
const promptSnippet = buildToolPromptSnippet({
|
|
1351
|
+
description,
|
|
1352
|
+
inputSchema: tool.inputSchema,
|
|
1353
|
+
serverId
|
|
1354
|
+
});
|
|
1355
|
+
pi.registerTool(withSummaryStrippedBeforeValidation({
|
|
956
1356
|
name,
|
|
957
1357
|
label: `MCP: ${serverId}/${tool.name}`,
|
|
958
1358
|
description,
|
|
@@ -986,22 +1386,29 @@ var McpExtension = class {
|
|
|
986
1386
|
};
|
|
987
1387
|
}
|
|
988
1388
|
}
|
|
989
|
-
});
|
|
1389
|
+
}));
|
|
990
1390
|
this.registeredMcpToolNames.add(name);
|
|
991
1391
|
}
|
|
992
|
-
async reconcile({ pi, configPath, connectTimeoutMs }) {
|
|
1392
|
+
async reconcile({ pi, configPath, connectTimeoutMs, contextWindow }) {
|
|
993
1393
|
let config;
|
|
994
1394
|
try {
|
|
995
1395
|
config = await loadMcpConfig(configPath);
|
|
996
1396
|
} catch (err) {
|
|
997
1397
|
throw new Error(`Failed to load MCP config: ${err instanceof Error ? err.message : String(err)}`);
|
|
998
1398
|
}
|
|
1399
|
+
const limits = resolveMcpToolLimits();
|
|
1400
|
+
const tokenBudget = resolveTokenBudget(limits, contextWindow);
|
|
999
1401
|
const summary = {
|
|
1000
1402
|
added: [],
|
|
1001
1403
|
removed: [],
|
|
1002
1404
|
refreshed: [],
|
|
1003
1405
|
errors: [],
|
|
1004
1406
|
totalTools: 0,
|
|
1407
|
+
droppedTools: 0,
|
|
1408
|
+
limits,
|
|
1409
|
+
tokenBudget,
|
|
1410
|
+
tokensUsed: 0,
|
|
1411
|
+
toolCounts: {},
|
|
1005
1412
|
servers: {}
|
|
1006
1413
|
};
|
|
1007
1414
|
const desiredIds = new Set(Object.keys(config.servers));
|
|
@@ -1025,15 +1432,58 @@ var McpExtension = class {
|
|
|
1025
1432
|
if (outcome.error) summary.errors.push(outcome.error);
|
|
1026
1433
|
if (outcome.change === "added") summary.added.push(id);
|
|
1027
1434
|
else if (outcome.change === "refreshed") summary.refreshed.push(id);
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1435
|
+
}
|
|
1436
|
+
const alloc = outcomes.filter((o) => Boolean(o.tools)).map((o) => {
|
|
1437
|
+
const advertised = o.tools.list.length;
|
|
1438
|
+
return {
|
|
1439
|
+
id: o.id,
|
|
1440
|
+
client: o.tools.client,
|
|
1441
|
+
list: o.tools.list,
|
|
1442
|
+
advertised,
|
|
1443
|
+
room: Math.min(advertised, limits.maxToolsPerServer),
|
|
1444
|
+
registered: 0,
|
|
1445
|
+
serverTokens: 0,
|
|
1446
|
+
droppedReason: null
|
|
1447
|
+
};
|
|
1448
|
+
});
|
|
1449
|
+
const maxAdvertised = alloc.reduce((m, s) => Math.max(m, s.advertised), 0);
|
|
1450
|
+
outer: for (let i = 0; i < maxAdvertised; i++) for (const s of alloc) {
|
|
1451
|
+
if (summary.totalTools >= limits.maxTotalTools) {
|
|
1452
|
+
for (const t of alloc) if (t.registered < t.advertised && t.droppedReason === null) t.droppedReason = "total";
|
|
1453
|
+
break outer;
|
|
1454
|
+
}
|
|
1455
|
+
if (i >= s.room) {
|
|
1456
|
+
if (s.registered < s.advertised && s.droppedReason === null) s.droppedReason = "per_server";
|
|
1457
|
+
continue;
|
|
1036
1458
|
}
|
|
1459
|
+
const tool = s.list[i];
|
|
1460
|
+
if (tool === void 0) continue;
|
|
1461
|
+
const cost = estimateToolTokens(tool);
|
|
1462
|
+
if (summary.tokensUsed + cost > tokenBudget && summary.totalTools > 0) {
|
|
1463
|
+
for (const t of alloc) if (t.registered < t.advertised && t.droppedReason === null) t.droppedReason = "tokens";
|
|
1464
|
+
break outer;
|
|
1465
|
+
}
|
|
1466
|
+
this.registerMcpTool({
|
|
1467
|
+
pi,
|
|
1468
|
+
serverId: s.id,
|
|
1469
|
+
client: s.client,
|
|
1470
|
+
tool
|
|
1471
|
+
});
|
|
1472
|
+
s.registered++;
|
|
1473
|
+
s.serverTokens += cost;
|
|
1474
|
+
summary.totalTools++;
|
|
1475
|
+
summary.tokensUsed += cost;
|
|
1476
|
+
}
|
|
1477
|
+
for (const s of alloc) {
|
|
1478
|
+
const dropped = s.advertised - s.registered;
|
|
1479
|
+
const droppedReason = dropped > 0 ? s.droppedReason : null;
|
|
1480
|
+
if (dropped > 0) summary.droppedTools += dropped;
|
|
1481
|
+
summary.toolCounts[s.id] = {
|
|
1482
|
+
advertised: s.advertised,
|
|
1483
|
+
registered: s.registered,
|
|
1484
|
+
tokens: s.serverTokens,
|
|
1485
|
+
droppedReason
|
|
1486
|
+
};
|
|
1037
1487
|
}
|
|
1038
1488
|
return summary;
|
|
1039
1489
|
}
|
|
@@ -1098,7 +1548,8 @@ var McpExtension = class {
|
|
|
1098
1548
|
},
|
|
1099
1549
|
serverStatus: {
|
|
1100
1550
|
status: "timeout",
|
|
1101
|
-
stderr: result.stderr
|
|
1551
|
+
stderr: result.stderr,
|
|
1552
|
+
diagnostics: result.diagnostics
|
|
1102
1553
|
},
|
|
1103
1554
|
change,
|
|
1104
1555
|
error: null,
|
|
@@ -1145,7 +1596,8 @@ var McpExtension = class {
|
|
|
1145
1596
|
},
|
|
1146
1597
|
serverStatus: {
|
|
1147
1598
|
status: "timeout",
|
|
1148
|
-
stderr: retry.stderr
|
|
1599
|
+
stderr: retry.stderr,
|
|
1600
|
+
diagnostics: retry.diagnostics
|
|
1149
1601
|
},
|
|
1150
1602
|
change: null,
|
|
1151
1603
|
error: null,
|
|
@@ -1178,7 +1630,8 @@ var McpExtension = class {
|
|
|
1178
1630
|
store: connected,
|
|
1179
1631
|
serverStatus: {
|
|
1180
1632
|
status: "timeout",
|
|
1181
|
-
stderr
|
|
1633
|
+
stderr,
|
|
1634
|
+
diagnostics: null
|
|
1182
1635
|
},
|
|
1183
1636
|
change: null,
|
|
1184
1637
|
error: null,
|
|
@@ -1189,6 +1642,64 @@ var McpExtension = class {
|
|
|
1189
1642
|
try {
|
|
1190
1643
|
mcpTools = (await connected.client.listTools()).tools;
|
|
1191
1644
|
} catch (err) {
|
|
1645
|
+
if (isUnauthorizedError(err) && serverConfig.transport === "http") {
|
|
1646
|
+
await closeConnected(connected);
|
|
1647
|
+
const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
|
|
1648
|
+
if (retry.status === "pending_auth") return {
|
|
1649
|
+
id,
|
|
1650
|
+
store: {
|
|
1651
|
+
client: retry.client,
|
|
1652
|
+
configKey,
|
|
1653
|
+
status: "pending_auth",
|
|
1654
|
+
stderrBuffer: null,
|
|
1655
|
+
cliHint: retry.cliHint
|
|
1656
|
+
},
|
|
1657
|
+
serverStatus: {
|
|
1658
|
+
status: "pending_auth",
|
|
1659
|
+
stderr: "",
|
|
1660
|
+
cliHint: retry.cliHint
|
|
1661
|
+
},
|
|
1662
|
+
change: null,
|
|
1663
|
+
error: null,
|
|
1664
|
+
tools: null
|
|
1665
|
+
};
|
|
1666
|
+
if (retry.status === "failed") return {
|
|
1667
|
+
id,
|
|
1668
|
+
store: null,
|
|
1669
|
+
serverStatus: {
|
|
1670
|
+
status: "failed",
|
|
1671
|
+
error: retry.error,
|
|
1672
|
+
stderr: retry.stderr
|
|
1673
|
+
},
|
|
1674
|
+
change: null,
|
|
1675
|
+
error: {
|
|
1676
|
+
serverId: id,
|
|
1677
|
+
message: retry.error
|
|
1678
|
+
},
|
|
1679
|
+
tools: null
|
|
1680
|
+
};
|
|
1681
|
+
if (retry.status === "connected") {
|
|
1682
|
+
connected = {
|
|
1683
|
+
client: retry.client,
|
|
1684
|
+
configKey,
|
|
1685
|
+
status: "connected",
|
|
1686
|
+
stderrBuffer: retry.stderr,
|
|
1687
|
+
cliHint: null
|
|
1688
|
+
};
|
|
1689
|
+
mcpTools = (await connected.client.listTools()).tools;
|
|
1690
|
+
return {
|
|
1691
|
+
id,
|
|
1692
|
+
store: connected,
|
|
1693
|
+
serverStatus: { status: "connected" },
|
|
1694
|
+
change: action === "reused" ? "refreshed" : action,
|
|
1695
|
+
error: null,
|
|
1696
|
+
tools: {
|
|
1697
|
+
client: connected.client,
|
|
1698
|
+
list: mcpTools
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1192
1703
|
const message = err instanceof Error ? err.message : String(err);
|
|
1193
1704
|
const stderr = connected.stderrBuffer?.read() ?? "";
|
|
1194
1705
|
return {
|
|
@@ -1219,17 +1730,21 @@ var McpExtension = class {
|
|
|
1219
1730
|
}
|
|
1220
1731
|
};
|
|
1221
1732
|
}
|
|
1222
|
-
async reconcileAndRecordMtime({ pi, configPath, reason }) {
|
|
1733
|
+
async reconcileAndRecordMtime({ pi, configPath, reason, contextWindow }) {
|
|
1223
1734
|
const summary = await this.reconcile({
|
|
1224
1735
|
pi,
|
|
1225
|
-
configPath
|
|
1736
|
+
configPath,
|
|
1737
|
+
contextWindow
|
|
1226
1738
|
});
|
|
1227
1739
|
this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
|
|
1228
1740
|
if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
|
|
1229
|
-
log$
|
|
1741
|
+
log$13.info({
|
|
1230
1742
|
event: "mcp_reconcile",
|
|
1231
1743
|
reason,
|
|
1232
1744
|
total_tools: summary.totalTools,
|
|
1745
|
+
tokens_used: summary.tokensUsed,
|
|
1746
|
+
token_budget: summary.tokenBudget,
|
|
1747
|
+
dropped_tools: summary.droppedTools,
|
|
1233
1748
|
added: summary.added,
|
|
1234
1749
|
removed: summary.removed,
|
|
1235
1750
|
refreshed: summary.refreshed,
|
|
@@ -1246,10 +1761,11 @@ var McpExtension = class {
|
|
|
1246
1761
|
await this.reconcileAndRecordMtime({
|
|
1247
1762
|
pi,
|
|
1248
1763
|
configPath,
|
|
1249
|
-
reason: "session_start"
|
|
1764
|
+
reason: "session_start",
|
|
1765
|
+
contextWindow: ctx.model?.contextWindow
|
|
1250
1766
|
});
|
|
1251
1767
|
} catch (err) {
|
|
1252
|
-
log$
|
|
1768
|
+
log$13.error({
|
|
1253
1769
|
err,
|
|
1254
1770
|
event: "mcp_reconcile_failed"
|
|
1255
1771
|
}, "MCP reconcile failed");
|
|
@@ -1261,7 +1777,7 @@ var McpExtension = class {
|
|
|
1261
1777
|
try {
|
|
1262
1778
|
mtime = await readConfigMtimeMs(configPath);
|
|
1263
1779
|
} catch (err) {
|
|
1264
|
-
log$
|
|
1780
|
+
log$13.warn({
|
|
1265
1781
|
err,
|
|
1266
1782
|
event: "mcp_mtime_check_failed"
|
|
1267
1783
|
}, "mtime check on mcp.config.json failed");
|
|
@@ -1272,10 +1788,11 @@ var McpExtension = class {
|
|
|
1272
1788
|
await this.reconcileAndRecordMtime({
|
|
1273
1789
|
pi,
|
|
1274
1790
|
configPath,
|
|
1275
|
-
reason: "auto_reload"
|
|
1791
|
+
reason: "auto_reload",
|
|
1792
|
+
contextWindow: ctx.model?.contextWindow
|
|
1276
1793
|
});
|
|
1277
1794
|
} catch (err) {
|
|
1278
|
-
log$
|
|
1795
|
+
log$13.error({
|
|
1279
1796
|
err,
|
|
1280
1797
|
event: "mcp_auto_reload_failed"
|
|
1281
1798
|
}, "auto-reload after mcp.config.json change failed");
|
|
@@ -1292,7 +1809,8 @@ var McpExtension = class {
|
|
|
1292
1809
|
const summary = await this.reconcileAndRecordMtime({
|
|
1293
1810
|
pi,
|
|
1294
1811
|
configPath,
|
|
1295
|
-
reason: "tool"
|
|
1812
|
+
reason: "tool",
|
|
1813
|
+
contextWindow: ctx.model?.contextWindow
|
|
1296
1814
|
});
|
|
1297
1815
|
return {
|
|
1298
1816
|
content: [{
|
|
@@ -1334,9 +1852,21 @@ function pendingAuthEntries(summary) {
|
|
|
1334
1852
|
function timeoutEntries(summary) {
|
|
1335
1853
|
return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "timeout" ? [{
|
|
1336
1854
|
id,
|
|
1337
|
-
stderr: status.stderr
|
|
1855
|
+
stderr: status.stderr,
|
|
1856
|
+
diagnostics: status.diagnostics
|
|
1338
1857
|
}] : []);
|
|
1339
1858
|
}
|
|
1859
|
+
/**
|
|
1860
|
+
* One line describing what the connect probe learned about a timed-out
|
|
1861
|
+
* stdio child, so an empty stderr is no longer a dead end. Empty array
|
|
1862
|
+
* when the entry was re-surfaced without a fresh probe.
|
|
1863
|
+
*/
|
|
1864
|
+
function timeoutDiagnosticLines(diagnostics) {
|
|
1865
|
+
if (diagnostics === null) return [];
|
|
1866
|
+
const alive = diagnostics.alive === null ? "unknown (no pid)" : diagnostics.alive ? "alive" : "DEAD";
|
|
1867
|
+
const spoke = diagnostics.stdoutMessages === 0 ? "no JSON-RPC received on stdout (initialize never answered — slow startup, or blocked e.g. on OAuth)" : `${diagnostics.stdoutMessages} JSON-RPC message(s) received on stdout (server spoke but the handshake stalled)`;
|
|
1868
|
+
return [` child process: ${alive}${diagnostics.pid !== null ? ` (pid ${diagnostics.pid})` : ""}; ${spoke}`];
|
|
1869
|
+
}
|
|
1340
1870
|
function failedEntries(summary) {
|
|
1341
1871
|
return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "failed" ? [{
|
|
1342
1872
|
id,
|
|
@@ -1344,6 +1874,13 @@ function failedEntries(summary) {
|
|
|
1344
1874
|
stderr: status.stderr
|
|
1345
1875
|
}] : []);
|
|
1346
1876
|
}
|
|
1877
|
+
function appendTimeoutStderrBlock(lines, stderr) {
|
|
1878
|
+
if (!stderr) {
|
|
1879
|
+
lines.push(" stderr: (empty — the child wrote nothing to stderr)");
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
appendStderrBlock(lines, stderr);
|
|
1883
|
+
}
|
|
1347
1884
|
function appendStderrBlock(lines, stderr) {
|
|
1348
1885
|
if (!stderr) return;
|
|
1349
1886
|
lines.push(" stderr:");
|
|
@@ -1351,9 +1888,29 @@ function appendStderrBlock(lines, stderr) {
|
|
|
1351
1888
|
for (const line of stderr.trimEnd().split("\n")) lines.push(` ${line}`);
|
|
1352
1889
|
lines.push(" ---");
|
|
1353
1890
|
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Human-readable lines describing any budget-driven truncation, shared by
|
|
1893
|
+
* `summaryText` (the reload_mcp tool output) and `formatMcpUpdateMessage` (the
|
|
1894
|
+
* synthetic continuation). Empty when nothing was dropped.
|
|
1895
|
+
*/
|
|
1896
|
+
function truncationLines(summary) {
|
|
1897
|
+
if (summary.droppedTools <= 0) return [];
|
|
1898
|
+
const lines = [];
|
|
1899
|
+
const { maxTotalTools, maxToolsPerServer } = summary.limits;
|
|
1900
|
+
const totalCapHint = Number.isFinite(maxTotalTools) ? `${maxTotalTools}` : "unlimited";
|
|
1901
|
+
lines.push(` WARNING: ${summary.droppedTools} MCP tool(s) were NOT registered because a tool budget was hit (token budget: ~${summary.tokenBudget} tokens, used ~${summary.tokensUsed}; total-tool cap: ${totalCapHint}, per-server cap: ${Number.isFinite(maxToolsPerServer) ? maxToolsPerServer : "unlimited"}).`);
|
|
1902
|
+
lines.push(" Tool definitions are sent to the model on every prompt; too many (or too-large) tool schemas push the request over the limit, so they are budgeted against the model context window. Prune servers from mcp.config.json to bring the tool surface down.");
|
|
1903
|
+
for (const [id, count] of Object.entries(summary.toolCounts)) {
|
|
1904
|
+
if (count.droppedReason === null) continue;
|
|
1905
|
+
const why = count.droppedReason === "tokens" ? "token budget exhausted" : count.droppedReason === "total" ? "total-tool cap reached" : "per-server cap";
|
|
1906
|
+
lines.push(` - ${id}: registered ${count.registered}/${count.advertised} tools (~${count.tokens} tokens, ${why}).`);
|
|
1907
|
+
}
|
|
1908
|
+
return lines;
|
|
1909
|
+
}
|
|
1354
1910
|
function summaryText(summary) {
|
|
1355
1911
|
const lines = [];
|
|
1356
1912
|
lines.push(`MCP reconcile complete: ${summary.totalTools} tool(s) live.`);
|
|
1913
|
+
lines.push(...truncationLines(summary));
|
|
1357
1914
|
if (summary.added.length > 0) lines.push(` Added: ${summary.added.join(", ")}`);
|
|
1358
1915
|
if (summary.refreshed.length > 0) lines.push(` Refreshed: ${summary.refreshed.join(", ")}`);
|
|
1359
1916
|
if (summary.removed.length > 0) lines.push(` Removed: ${summary.removed.join(", ")}`);
|
|
@@ -1362,11 +1919,12 @@ function summaryText(summary) {
|
|
|
1362
1919
|
lines.push(` run \`${cliHint}\` in the shell to authenticate.`);
|
|
1363
1920
|
appendStderrBlock(lines, stderr);
|
|
1364
1921
|
}
|
|
1365
|
-
for (const { id, stderr } of timeoutEntries(summary)) {
|
|
1922
|
+
for (const { id, stderr, diagnostics } of timeoutEntries(summary)) {
|
|
1366
1923
|
lines.push(` TIMED OUT ${id}:`);
|
|
1367
1924
|
lines.push(` bridge spawned but did not complete initialize in time`);
|
|
1368
1925
|
lines.push(` (usually mcp-remote mid-OAuth — see captured stderr).`);
|
|
1369
|
-
|
|
1926
|
+
lines.push(...timeoutDiagnosticLines(diagnostics));
|
|
1927
|
+
appendTimeoutStderrBlock(lines, stderr);
|
|
1370
1928
|
}
|
|
1371
1929
|
for (const { id, error, stderr } of failedEntries(summary)) {
|
|
1372
1930
|
lines.push(` FAILED ${id}: ${error}`);
|
|
@@ -1379,7 +1937,7 @@ function summaryText(summary) {
|
|
|
1379
1937
|
return lines.join("\n");
|
|
1380
1938
|
}
|
|
1381
1939
|
function summaryHasChanges(summary) {
|
|
1382
|
-
return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0;
|
|
1940
|
+
return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0 || summary.droppedTools > 0;
|
|
1383
1941
|
}
|
|
1384
1942
|
/**
|
|
1385
1943
|
* Format a queued tool-update as a synthetic system-style message for
|
|
@@ -1392,6 +1950,10 @@ function formatMcpUpdateMessage(summary) {
|
|
|
1392
1950
|
if (summary.added.length > 0) lines.push(`Newly available servers: ${summary.added.join(", ")}`);
|
|
1393
1951
|
if (summary.refreshed.length > 0) lines.push(`Refreshed servers: ${summary.refreshed.join(", ")}`);
|
|
1394
1952
|
if (summary.removed.length > 0) lines.push(`Removed servers (and their tools): ${summary.removed.join(", ")}`);
|
|
1953
|
+
if (summary.droppedTools > 0) {
|
|
1954
|
+
lines.push("");
|
|
1955
|
+
lines.push(...truncationLines(summary));
|
|
1956
|
+
}
|
|
1395
1957
|
const pending = pendingAuthEntries(summary);
|
|
1396
1958
|
if (pending.length > 0) {
|
|
1397
1959
|
lines.push("");
|
|
@@ -1407,9 +1969,10 @@ function formatMcpUpdateMessage(summary) {
|
|
|
1407
1969
|
if (timedOut.length > 0) {
|
|
1408
1970
|
lines.push("");
|
|
1409
1971
|
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) {
|
|
1972
|
+
for (const { id, stderr, diagnostics } of timedOut) {
|
|
1411
1973
|
lines.push(` - ${id}:`);
|
|
1412
|
-
|
|
1974
|
+
lines.push(...timeoutDiagnosticLines(diagnostics));
|
|
1975
|
+
appendTimeoutStderrBlock(lines, stderr);
|
|
1413
1976
|
}
|
|
1414
1977
|
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
1978
|
}
|
|
@@ -1498,156 +2061,16 @@ async function runToolUpdateLoop({ session, log }) {
|
|
|
1498
2061
|
}, "reached tool-update continuation cap; further updates will land on next request");
|
|
1499
2062
|
}
|
|
1500
2063
|
//#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
2064
|
//#region src/channel-context-ref.ts
|
|
1642
2065
|
/**
|
|
1643
|
-
* The worker injects
|
|
1644
|
-
* sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
2066
|
+
* The worker injects a small reference — `{ channel, messageId, runId }` —
|
|
2067
|
+
* into the sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
1645
2068
|
*
|
|
1646
2069
|
* The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
|
|
1647
2070
|
* `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
|
|
1648
2071
|
* `@createinc/*` dependencies — importing that package would pull the whole
|
|
1649
|
-
* platform channel stack (Slack/email/Linq SDKs, messaging)
|
|
1650
|
-
*
|
|
2072
|
+
* platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
|
|
2073
|
+
* field. So we validate the field this consumer needs locally instead.
|
|
1651
2074
|
*/
|
|
1652
2075
|
const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
|
|
1653
2076
|
/**
|
|
@@ -1679,31 +2102,96 @@ function apiBaseUrl() {
|
|
|
1679
2102
|
}
|
|
1680
2103
|
//#endregion
|
|
1681
2104
|
//#region src/extensions/platform.ts
|
|
2105
|
+
/**
|
|
2106
|
+
* Platform extension — bridges the agent harness to the Skydive platform daemon.
|
|
2107
|
+
*
|
|
2108
|
+
* Responsibilities:
|
|
2109
|
+
* - Heartbeat: periodic POST to the API so the sandbox manager knows the
|
|
2110
|
+
* agent is alive. Throttled to once per minute, triggered by tool events.
|
|
2111
|
+
* - Session tracking: registers the session with the daemon on start,
|
|
2112
|
+
* streams tool_call / tool_result events so the daemon can track which
|
|
2113
|
+
* session is actively executing, and signals session end on agent_end.
|
|
2114
|
+
* - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
|
|
2115
|
+
* messageId) to the daemon so file writes can be attributed to the
|
|
2116
|
+
* correct conversation.
|
|
2117
|
+
*
|
|
2118
|
+
* All daemon POSTs are fire-and-forget — failures are logged but never
|
|
2119
|
+
* block the agent. The daemon may not be running (e.g. local dev without
|
|
2120
|
+
* a sandbox), and that's fine.
|
|
2121
|
+
*/
|
|
1682
2122
|
const HEARTBEAT_THROTTLE_MS = 6e4;
|
|
1683
2123
|
const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1684
2124
|
const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
|
|
1685
2125
|
const DAEMON_URL = "http://localhost:38994";
|
|
1686
|
-
const log$
|
|
2126
|
+
const log$12 = logger.child({ module: "platform-ext" });
|
|
1687
2127
|
function sandboxClient() {
|
|
1688
2128
|
const apiUrl = apiBaseUrl();
|
|
1689
2129
|
if (!apiUrl) return null;
|
|
1690
2130
|
return hc(`${apiUrl}/api/v1/sandbox`);
|
|
1691
2131
|
}
|
|
1692
2132
|
/**
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
2133
|
+
* Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
|
|
2134
|
+
* feature-flags half of the ANY-5184 pool 403 wave.)
|
|
2135
|
+
*
|
|
2136
|
+
* `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
|
|
2137
|
+
* from a pool box can only 403 — a guaranteed-failing GET every 60s for the
|
|
2138
|
+
* life of the pool phase. The discriminator is the sandbox token's `type`
|
|
2139
|
+
* claim, read UNVERIFIED (this box never holds the signing secret): not an
|
|
2140
|
+
* authorization decision, only "should I bother calling?", and the api still
|
|
2141
|
+
* authorizes every request.
|
|
2142
|
+
*
|
|
2143
|
+
* Read per call from the daemon's persisted env file, NOT process.env:
|
|
2144
|
+
* claiming a pool box rebinds the token in place (the daemon rewrites this
|
|
2145
|
+
* file) while the harness's process.env keeps the boot snapshot, so a
|
|
2146
|
+
* process-env gate would leave a claimed box permanently skipping — trading a
|
|
2147
|
+
* wasted request for silently frozen flags, which is strictly worse. "Cannot
|
|
2148
|
+
* tell" (no file, no token, unparseable payload) reports false so the poll
|
|
2149
|
+
* proceeds.
|
|
2150
|
+
*/
|
|
2151
|
+
const daemonEnvIdentitySchema = z.object({
|
|
2152
|
+
ANYONE_SANDBOX_TOKEN: z.string().optional(),
|
|
2153
|
+
SKYDIVE_SANDBOX_TOKEN: z.string().optional()
|
|
2154
|
+
}).passthrough();
|
|
2155
|
+
const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
|
|
2156
|
+
async function isPoolIdentity() {
|
|
2157
|
+
try {
|
|
2158
|
+
const override = process.env.ANYONE_DAEMON_ENV_CACHE;
|
|
2159
|
+
const candidates = override ? [override] : ["/run/anyone-system/daemon-env.json", "/tmp/.anyone/daemon-env.json"];
|
|
2160
|
+
let raw = null;
|
|
2161
|
+
for (const file of candidates) {
|
|
2162
|
+
raw = await readFile(file, "utf8").catch(() => null);
|
|
2163
|
+
if (raw !== null) break;
|
|
2164
|
+
}
|
|
2165
|
+
if (raw === null) return false;
|
|
2166
|
+
const env = daemonEnvIdentitySchema.safeParse(JSON.parse(raw));
|
|
2167
|
+
if (!env.success) return false;
|
|
2168
|
+
const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
|
|
2169
|
+
if (typeof token !== "string" || token === "") return false;
|
|
2170
|
+
const payload = token.split(".")[1];
|
|
2171
|
+
if (!payload) return false;
|
|
2172
|
+
const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
2173
|
+
return claims.success && claims.data.type === "onboarding-pool";
|
|
2174
|
+
} catch (_err) {
|
|
2175
|
+
return false;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
|
|
2180
|
+
* — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
|
|
2181
|
+
* null when indeterminate (no api url, the request failed, or the box is an
|
|
2182
|
+
* unclaimed pool sandbox whose token the route would 403) so the shared
|
|
2183
|
+
* poller keeps the last-known values rather than flipping on a transient error.
|
|
1697
2184
|
* This is the single fetch behind `feature-flags-poll.ts`; extensions read the
|
|
1698
2185
|
* polled values there instead of issuing their own GET.
|
|
1699
2186
|
*/
|
|
1700
2187
|
async function fetchHarnessFlags() {
|
|
1701
2188
|
const client = sandboxClient();
|
|
1702
2189
|
if (!client) return null;
|
|
2190
|
+
if (await isPoolIdentity()) return null;
|
|
1703
2191
|
try {
|
|
1704
2192
|
const res = await client["feature-flags"].$get();
|
|
1705
2193
|
if (!res.ok) {
|
|
1706
|
-
log$
|
|
2194
|
+
log$12.debug({
|
|
1707
2195
|
status: res.status,
|
|
1708
2196
|
event: "feature_flags_fetch_failed"
|
|
1709
2197
|
}, "feature-flags fetch failed");
|
|
@@ -1712,10 +2200,10 @@ async function fetchHarnessFlags() {
|
|
|
1712
2200
|
const body = await res.json();
|
|
1713
2201
|
return {
|
|
1714
2202
|
contextManagement: body.contextManagement ?? null,
|
|
1715
|
-
|
|
2203
|
+
closingText: body.closingText ?? null
|
|
1716
2204
|
};
|
|
1717
2205
|
} catch (err) {
|
|
1718
|
-
log$
|
|
2206
|
+
log$12.debug({
|
|
1719
2207
|
err,
|
|
1720
2208
|
event: "feature_flags_fetch_error"
|
|
1721
2209
|
}, "feature-flags request errored");
|
|
@@ -1726,7 +2214,7 @@ function postHeartbeat({ messageId }) {
|
|
|
1726
2214
|
const client = sandboxClient();
|
|
1727
2215
|
if (!client) return;
|
|
1728
2216
|
client.heartbeat.$post({ json: { messageId } }).catch((err) => {
|
|
1729
|
-
log$
|
|
2217
|
+
log$12.debug({
|
|
1730
2218
|
err,
|
|
1731
2219
|
event: "heartbeat_failed"
|
|
1732
2220
|
}, "heartbeat failed");
|
|
@@ -1738,7 +2226,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1738
2226
|
try {
|
|
1739
2227
|
const res = await client["message-conversation"].$get({ query: { messageId } });
|
|
1740
2228
|
if (!res.ok) {
|
|
1741
|
-
log$
|
|
2229
|
+
log$12.warn({
|
|
1742
2230
|
status: res.status,
|
|
1743
2231
|
messageId,
|
|
1744
2232
|
event: "resolve_conversation_failed"
|
|
@@ -1747,7 +2235,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1747
2235
|
}
|
|
1748
2236
|
return (await res.json()).conversationId ?? null;
|
|
1749
2237
|
} catch (err) {
|
|
1750
|
-
log$
|
|
2238
|
+
log$12.warn({
|
|
1751
2239
|
err,
|
|
1752
2240
|
messageId,
|
|
1753
2241
|
event: "resolve_conversation_error"
|
|
@@ -1764,6 +2252,71 @@ async function postBackgroundTaskDone({ messageId, content }) {
|
|
|
1764
2252
|
} });
|
|
1765
2253
|
if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
|
|
1766
2254
|
}
|
|
2255
|
+
async function putBackgroundTaskJournalSpec({ messageId, spec }) {
|
|
2256
|
+
const client = sandboxClient();
|
|
2257
|
+
if (!client) return;
|
|
2258
|
+
try {
|
|
2259
|
+
const res = await client["bg-task-journal"].$put({ json: {
|
|
2260
|
+
messageId,
|
|
2261
|
+
spec
|
|
2262
|
+
} });
|
|
2263
|
+
if (!res.ok) log$12.warn({
|
|
2264
|
+
status: res.status,
|
|
2265
|
+
taskId: spec.id,
|
|
2266
|
+
event: "bg_journal_put_failed"
|
|
2267
|
+
}, "bg-task journal PUT failed");
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
log$12.warn({
|
|
2270
|
+
err,
|
|
2271
|
+
taskId: spec.id,
|
|
2272
|
+
event: "bg_journal_put_failed"
|
|
2273
|
+
}, "bg-task journal PUT threw");
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
async function deleteBackgroundTaskJournalSpec({ messageId, taskId }) {
|
|
2277
|
+
const client = sandboxClient();
|
|
2278
|
+
if (!client) return;
|
|
2279
|
+
try {
|
|
2280
|
+
await client["bg-task-journal"].$delete({ json: {
|
|
2281
|
+
messageId,
|
|
2282
|
+
taskId
|
|
2283
|
+
} });
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
log$12.debug({
|
|
2286
|
+
err,
|
|
2287
|
+
taskId,
|
|
2288
|
+
event: "bg_journal_delete_failed"
|
|
2289
|
+
}, "bg-task journal DELETE failed");
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
async function listBackgroundTaskJournalSpecs({ messageId }) {
|
|
2293
|
+
const client = sandboxClient();
|
|
2294
|
+
if (!client) return [];
|
|
2295
|
+
try {
|
|
2296
|
+
const res = await client["bg-task-journal"].$get({ query: { messageId } });
|
|
2297
|
+
if (!res.ok) return [];
|
|
2298
|
+
return (await res.json()).specs ?? [];
|
|
2299
|
+
} catch (err) {
|
|
2300
|
+
log$12.debug({
|
|
2301
|
+
err,
|
|
2302
|
+
event: "bg_journal_list_failed"
|
|
2303
|
+
}, "bg-task journal GET failed");
|
|
2304
|
+
return [];
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
function postBackgroundTasksSnapshot({ messageId, tasks }) {
|
|
2308
|
+
const client = sandboxClient();
|
|
2309
|
+
if (!client || !messageId) return;
|
|
2310
|
+
client["bg-tasks"].$post({ json: {
|
|
2311
|
+
messageId,
|
|
2312
|
+
tasks
|
|
2313
|
+
} }).catch((err) => {
|
|
2314
|
+
log$12.debug({
|
|
2315
|
+
err,
|
|
2316
|
+
event: "bg_tasks_snapshot_failed"
|
|
2317
|
+
}, "bg-tasks snapshot publish failed");
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
1767
2320
|
async function postSubagentSpawn({ messageId, tasks }) {
|
|
1768
2321
|
const client = sandboxClient();
|
|
1769
2322
|
if (!client) throw new Error("no api url for subagent-spawn");
|
|
@@ -1771,8 +2324,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
|
|
|
1771
2324
|
messageId,
|
|
1772
2325
|
tasks
|
|
1773
2326
|
} });
|
|
1774
|
-
if (!res.ok)
|
|
1775
|
-
|
|
2327
|
+
if (!res.ok) {
|
|
2328
|
+
let detail = "";
|
|
2329
|
+
try {
|
|
2330
|
+
const errBody = await res.json();
|
|
2331
|
+
if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
|
|
2332
|
+
} catch {}
|
|
2333
|
+
throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
|
|
2334
|
+
}
|
|
2335
|
+
const body = await res.json();
|
|
2336
|
+
return {
|
|
2337
|
+
taskIds: body.taskIds,
|
|
2338
|
+
tasks: body.tasks ?? []
|
|
2339
|
+
};
|
|
1776
2340
|
}
|
|
1777
2341
|
function createHeartbeatThrottle({ messageId }) {
|
|
1778
2342
|
let lastAt = 0;
|
|
@@ -1821,7 +2385,7 @@ function createToolHeartbeat({ messageId }) {
|
|
|
1821
2385
|
}
|
|
1822
2386
|
heartbeatCount++;
|
|
1823
2387
|
if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
|
|
1824
|
-
log$
|
|
2388
|
+
log$12.warn({
|
|
1825
2389
|
heartbeatCount,
|
|
1826
2390
|
activeToolCalls: [...activeToolCalls]
|
|
1827
2391
|
}, "tool heartbeat max reached, stopping");
|
|
@@ -1852,7 +2416,7 @@ function postToDaemon(path, body) {
|
|
|
1852
2416
|
headers: { "content-type": "application/json" },
|
|
1853
2417
|
body: JSON.stringify(body)
|
|
1854
2418
|
}).catch((err) => {
|
|
1855
|
-
log$
|
|
2419
|
+
log$12.debug({
|
|
1856
2420
|
err,
|
|
1857
2421
|
path,
|
|
1858
2422
|
event: "daemon_post_failed"
|
|
@@ -1861,7 +2425,7 @@ function postToDaemon(path, body) {
|
|
|
1861
2425
|
}
|
|
1862
2426
|
function createPlatformExtensions({ sessionId, channelContext }) {
|
|
1863
2427
|
return (pi) => {
|
|
1864
|
-
log$
|
|
2428
|
+
log$12.info({
|
|
1865
2429
|
sessionId,
|
|
1866
2430
|
hasChannelContext: Boolean(channelContext)
|
|
1867
2431
|
}, "platform extension initialized");
|
|
@@ -1910,90 +2474,793 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1910
2474
|
});
|
|
1911
2475
|
});
|
|
1912
2476
|
pi.on("agent_end", () => {
|
|
1913
|
-
log$
|
|
2477
|
+
log$12.info({ sessionId }, "session ending");
|
|
1914
2478
|
postToDaemon("/session/end", { sessionId });
|
|
1915
2479
|
});
|
|
1916
2480
|
};
|
|
1917
2481
|
}
|
|
1918
2482
|
//#endregion
|
|
1919
|
-
//#region src/extensions/
|
|
2483
|
+
//#region src/extensions/context-management-config.ts
|
|
2484
|
+
/**
|
|
2485
|
+
* Configuration for the context-management capability (tool-output trimming
|
|
2486
|
+
* and client-side microcompact). See the design notes in the extension for
|
|
2487
|
+
* what each layer does; this module is purely the env → config surface.
|
|
2488
|
+
*
|
|
2489
|
+
* The harness is provider-agnostic and runs inside the sandbox, where its
|
|
2490
|
+
* only config channel is the environment (loaded by the platform env
|
|
2491
|
+
* middleware before any session starts — see platform-env-middleware.ts).
|
|
2492
|
+
* `SKYDIVE_CONTEXT_MANAGEMENT` is the env-side master toggle; the individual
|
|
2493
|
+
* `SKYDIVE_CTX_*` knobs override the defaults below only when that toggle is
|
|
2494
|
+
* set. Prod never sets it: the LaunchDarkly flag
|
|
2495
|
+
* `harness-context-management-enabled` reaches running sandboxes through the
|
|
2496
|
+
* feature-flag poller (context-management-runtime.ts), which overlays only
|
|
2497
|
+
* `enabled`. So in prod every knob resolves to its default regardless of what
|
|
2498
|
+
* the platform forwards — see the knob-gating follow-up before relying on one.
|
|
2499
|
+
*
|
|
2500
|
+
* Resolution is defensive: a malformed value never throws (this config is
|
|
2501
|
+
* read on the hot path before every LLM call), it falls back to the default
|
|
2502
|
+
* for that knob and logs once.
|
|
2503
|
+
*/
|
|
2504
|
+
const log$11 = logger.child({ module: "context-management-config" });
|
|
2505
|
+
const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
|
|
2506
|
+
enabled: false,
|
|
2507
|
+
perResultMaxBytes: 16 * 1024,
|
|
2508
|
+
keepRecentToolResults: 3,
|
|
2509
|
+
coldCacheGapSeconds: 240,
|
|
2510
|
+
warmClearTriggerTokens: 5e4,
|
|
2511
|
+
clearAtLeastTokens: 1e4,
|
|
2512
|
+
excludeTools: [],
|
|
2513
|
+
nativeAnthropicEdits: false
|
|
2514
|
+
};
|
|
2515
|
+
const boolFromEnv = (value, fallback) => {
|
|
2516
|
+
if (value === void 0) return fallback;
|
|
2517
|
+
const normalized = value.trim().toLowerCase();
|
|
2518
|
+
if (normalized === "1" || normalized === "true") return true;
|
|
2519
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
2520
|
+
return fallback;
|
|
2521
|
+
};
|
|
2522
|
+
const positiveInt = (fallback) => z.coerce.number().int().positive().catch(fallback);
|
|
2523
|
+
const nonNegativeInt = (fallback) => z.coerce.number().int().nonnegative().catch(fallback);
|
|
2524
|
+
const configSchema = z.object({
|
|
2525
|
+
perResultMaxBytes: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.perResultMaxBytes),
|
|
2526
|
+
keepRecentToolResults: nonNegativeInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.keepRecentToolResults),
|
|
2527
|
+
coldCacheGapSeconds: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.coldCacheGapSeconds),
|
|
2528
|
+
warmClearTriggerTokens: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.warmClearTriggerTokens),
|
|
2529
|
+
clearAtLeastTokens: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.clearAtLeastTokens)
|
|
2530
|
+
});
|
|
2531
|
+
const parseExcludeTools = (value) => {
|
|
2532
|
+
if (!value) return DEFAULT_CONTEXT_MANAGEMENT_CONFIG.excludeTools;
|
|
2533
|
+
return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
|
|
2534
|
+
};
|
|
2535
|
+
/**
|
|
2536
|
+
* Reads the context-management config from `env` (defaults to `process.env`).
|
|
2537
|
+
* Never throws — invalid values fall back to defaults.
|
|
2538
|
+
*/
|
|
2539
|
+
function resolveContextManagementConfig(env = process.env) {
|
|
2540
|
+
if (!boolFromEnv(env.SKYDIVE_CONTEXT_MANAGEMENT, false)) return { ...DEFAULT_CONTEXT_MANAGEMENT_CONFIG };
|
|
2541
|
+
const parsed = configSchema.safeParse({
|
|
2542
|
+
perResultMaxBytes: env.SKYDIVE_CTX_PER_RESULT_MAX_BYTES,
|
|
2543
|
+
keepRecentToolResults: env.SKYDIVE_CTX_KEEP_RECENT,
|
|
2544
|
+
coldCacheGapSeconds: env.SKYDIVE_CTX_COLD_GAP_SECONDS,
|
|
2545
|
+
warmClearTriggerTokens: env.SKYDIVE_CTX_WARM_TRIGGER_TOKENS,
|
|
2546
|
+
clearAtLeastTokens: env.SKYDIVE_CTX_CLEAR_AT_LEAST_TOKENS
|
|
2547
|
+
});
|
|
2548
|
+
if (!parsed.success) {
|
|
2549
|
+
log$11.warn({
|
|
2550
|
+
event: "context_management_config_invalid",
|
|
2551
|
+
err: parsed.error
|
|
2552
|
+
}, "falling back to default context-management config");
|
|
2553
|
+
return {
|
|
2554
|
+
...DEFAULT_CONTEXT_MANAGEMENT_CONFIG,
|
|
2555
|
+
enabled: true
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
return {
|
|
2559
|
+
enabled: true,
|
|
2560
|
+
...parsed.data,
|
|
2561
|
+
excludeTools: parseExcludeTools(env.SKYDIVE_CTX_EXCLUDE_TOOLS),
|
|
2562
|
+
nativeAnthropicEdits: boolFromEnv(env.SKYDIVE_CTX_NATIVE_ANTHROPIC_EDITS, DEFAULT_CONTEXT_MANAGEMENT_CONFIG.nativeAnthropicEdits)
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
//#endregion
|
|
2566
|
+
//#region src/extensions/context-management-runtime.ts
|
|
2567
|
+
/**
|
|
2568
|
+
* Live, mutable view of the context-management config.
|
|
2569
|
+
*
|
|
2570
|
+
* The env-derived config (context-management-config.ts) is the boot-time
|
|
2571
|
+
* default. On top of it, the platform can deliver a global on/off at runtime
|
|
2572
|
+
* via the LaunchDarkly flag `harness-context-management-enabled` — resolved
|
|
2573
|
+
* server-side and polled by the harness (see the poller in
|
|
2574
|
+
* context-management.ts). This holder is where that override lands so a flip
|
|
2575
|
+
* (especially a kill-switch) reaches long-lived sandboxes without a restart.
|
|
2576
|
+
*
|
|
2577
|
+
* Only the master `enabled` toggle is overridable at runtime; the per-knob
|
|
2578
|
+
* tunables stay env-derived. `enabled` resolves to the flag override when the
|
|
2579
|
+
* platform has reported one, else the env value.
|
|
2580
|
+
*/
|
|
2581
|
+
let baseConfig = null;
|
|
2582
|
+
let flagOverride = null;
|
|
2583
|
+
function base() {
|
|
2584
|
+
baseConfig ??= resolveContextManagementConfig();
|
|
2585
|
+
return baseConfig;
|
|
2586
|
+
}
|
|
2587
|
+
/** The effective config, with the runtime flag override applied to `enabled`. */
|
|
2588
|
+
function getContextManagementConfig() {
|
|
2589
|
+
const resolved = base();
|
|
2590
|
+
return {
|
|
2591
|
+
...resolved,
|
|
2592
|
+
enabled: flagOverride ?? resolved.enabled
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Apply the platform-reported flag value. `null` clears the override (fall back
|
|
2597
|
+
* to the env default) — used when the phone-home result is indeterminate so a
|
|
2598
|
+
* transient failure never silently changes behavior.
|
|
2599
|
+
*/
|
|
2600
|
+
function setContextManagementFlagOverride(enabled) {
|
|
2601
|
+
flagOverride = enabled;
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Whether a phone-home channel exists to learn the flag at runtime. When false
|
|
2605
|
+
* (e.g. a bare local CLI with no platform API), the env value is the only
|
|
2606
|
+
* source and there's nothing to poll.
|
|
2607
|
+
*/
|
|
2608
|
+
function hasFlagSource(env = process.env) {
|
|
2609
|
+
return Boolean(env.SKYDIVE_API_URL ?? env.ANYONE_API_URL);
|
|
2610
|
+
}
|
|
2611
|
+
//#endregion
|
|
2612
|
+
//#region src/extensions/feature-flags-poll.ts
|
|
2613
|
+
/**
|
|
2614
|
+
* Shared harness feature-flag poll.
|
|
2615
|
+
*
|
|
2616
|
+
* The api exposes one `/feature-flags` GET that returns every harness flag in a
|
|
2617
|
+
* single response (`{ contextManagement, closingText, commandFlags }` — see
|
|
2618
|
+
* apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
|
|
2619
|
+
* extension issuing its own GET — and, worse, a *blocking* GET on the
|
|
2620
|
+
* pre-first-token `session_start` path — a single background poller fetches
|
|
2621
|
+
* that response once per interval and fans the values out to every subscriber.
|
|
2622
|
+
*
|
|
2623
|
+
* Why one poller: context-management consumes the `contextManagement` flag and
|
|
2624
|
+
* the closing-text loop consumes `closingText`, both without a blocking GET on
|
|
2625
|
+
* the pre-first-token `session_start` path. Reading the last-polled value keeps
|
|
2626
|
+
* the hot path allocation-only; a cold cache reads as `null` and a
|
|
2627
|
+
* newly-flipped flag takes effect on the next poll.
|
|
2628
|
+
*
|
|
2629
|
+
* The poll is fire-and-forget and self-unref'd — it never keeps the process
|
|
2630
|
+
* alive and an indeterminate result (no api url / transient failure) leaves the
|
|
2631
|
+
* last-known values untouched so a blip can't silently flip behavior.
|
|
2632
|
+
*/
|
|
2633
|
+
const log$10 = logger.child({ module: "feature-flags-poll" });
|
|
2634
|
+
const FLAG_POLL_INTERVAL_MS = 6e4;
|
|
2635
|
+
let contextManagement = null;
|
|
2636
|
+
let closingText = null;
|
|
2637
|
+
const subscribers = {
|
|
2638
|
+
contextManagement: /* @__PURE__ */ new Set(),
|
|
2639
|
+
closingText: /* @__PURE__ */ new Set()
|
|
2640
|
+
};
|
|
2641
|
+
let pollerStarted = false;
|
|
2642
|
+
let firstPollSettled = false;
|
|
2643
|
+
let resolveFirstPoll = null;
|
|
2644
|
+
new Promise((resolve) => {
|
|
2645
|
+
resolveFirstPoll = resolve;
|
|
2646
|
+
});
|
|
2647
|
+
function markFirstPollSettled() {
|
|
2648
|
+
if (firstPollSettled) return;
|
|
2649
|
+
firstPollSettled = true;
|
|
2650
|
+
resolveFirstPoll?.();
|
|
2651
|
+
}
|
|
2652
|
+
/** Last-polled value of a flag, or `null` if not yet resolved. */
|
|
2653
|
+
function getPolledFlag(name) {
|
|
2654
|
+
return name === "closingText" ? closingText : contextManagement;
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Subscribe to changes of a flag. The callback fires only on a *transition*
|
|
2658
|
+
* (skipped while the value is unchanged), so a subscriber registered before the
|
|
2659
|
+
* first poll still learns the initial value. Returns an unsubscribe fn.
|
|
2660
|
+
*/
|
|
2661
|
+
function onFlagChange(name, cb) {
|
|
2662
|
+
subscribers[name].add(cb);
|
|
2663
|
+
return () => subscribers[name].delete(cb);
|
|
2664
|
+
}
|
|
2665
|
+
function apply(name, next) {
|
|
2666
|
+
if (next === null) return;
|
|
2667
|
+
const prev = name === "closingText" ? closingText : contextManagement;
|
|
2668
|
+
if (name === "closingText") closingText = next;
|
|
2669
|
+
else contextManagement = next;
|
|
2670
|
+
if (next !== prev) for (const cb of subscribers[name]) try {
|
|
2671
|
+
cb(next);
|
|
2672
|
+
} catch (err) {
|
|
2673
|
+
log$10.warn({
|
|
2674
|
+
err,
|
|
2675
|
+
flag: name
|
|
2676
|
+
}, "flag subscriber threw");
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
async function pollOnce() {
|
|
2680
|
+
try {
|
|
2681
|
+
const flags = await fetchHarnessFlags();
|
|
2682
|
+
if (!flags) return;
|
|
2683
|
+
apply("contextManagement", flags.contextManagement ?? null);
|
|
2684
|
+
apply("closingText", flags.closingText ?? null);
|
|
2685
|
+
} catch (err) {
|
|
2686
|
+
log$10.debug({ err }, "feature-flag poll threw");
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Start the shared background poll (idempotent). No-op when there's no
|
|
2691
|
+
* phone-home channel (bare CLI): there's nothing to poll and callers keep their
|
|
2692
|
+
* env/boot default. Kicks an immediate poll, then repeats on an interval that
|
|
2693
|
+
* does not keep the process alive.
|
|
2694
|
+
*/
|
|
2695
|
+
function startFeatureFlagPoller() {
|
|
2696
|
+
if (pollerStarted || !hasFlagSource()) return;
|
|
2697
|
+
pollerStarted = true;
|
|
2698
|
+
pollOnce().finally(markFirstPollSettled);
|
|
2699
|
+
setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
|
|
2700
|
+
}
|
|
2701
|
+
//#endregion
|
|
2702
|
+
//#region src/closing-text-loop.ts
|
|
2703
|
+
const CLOSING_TEXT_NUDGE = "<system_notification>You ended that turn on a tool call without writing any reply to the user. Write a brief closing message now: what you did and the outcome. Do not call any more tools unless you genuinely still need to.</system_notification>";
|
|
2704
|
+
function isNonEmptyText(block) {
|
|
2705
|
+
return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* True when the assistant produced no visible text since the human's last
|
|
2709
|
+
* message this turn — i.e. the turn ended on tool calls / thinking only.
|
|
2710
|
+
*
|
|
2711
|
+
* We scan from the end back to the most recent `user` message (the human turn
|
|
2712
|
+
* that triggered this run) and inspect every `assistant` message after it.
|
|
2713
|
+
* A string-content assistant message (rare, but pi allows it) counts as text
|
|
2714
|
+
* when non-blank. `toolResult` and custom messages are ignored.
|
|
2715
|
+
*/
|
|
2716
|
+
function turnEndedWithoutVisibleText(messages) {
|
|
2717
|
+
let sawAssistant = false;
|
|
2718
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2719
|
+
const m = messages[i];
|
|
2720
|
+
if (!m || m.role === "user") break;
|
|
2721
|
+
if (m.role !== "assistant") continue;
|
|
2722
|
+
sawAssistant = true;
|
|
2723
|
+
if (typeof m.content === "string") {
|
|
2724
|
+
if (m.content.trim().length > 0) return false;
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
if (Array.isArray(m.content) && m.content.some(isNonEmptyText)) return false;
|
|
2728
|
+
}
|
|
2729
|
+
return sawAssistant;
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* True when the last assistant message this turn ended in a way we must not
|
|
2733
|
+
* nudge: a user abort or a provider error. Both already surface their own
|
|
2734
|
+
* terminal signal to the client; a closing-text prompt on top would be noise.
|
|
2735
|
+
*/
|
|
2736
|
+
function turnEndedAbnormally(messages) {
|
|
2737
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2738
|
+
const m = messages[i];
|
|
2739
|
+
if (!m || m.role !== "assistant") continue;
|
|
2740
|
+
return m.stopReason === "aborted" || m.stopReason === "error";
|
|
2741
|
+
}
|
|
2742
|
+
return false;
|
|
2743
|
+
}
|
|
2744
|
+
async function runClosingTextLoop({ session, log, enabled = getPolledFlag("closingText") === true }) {
|
|
2745
|
+
if (!enabled) return;
|
|
2746
|
+
if (turnEndedAbnormally(session.messages)) return;
|
|
2747
|
+
if (!turnEndedWithoutVisibleText(session.messages)) return;
|
|
2748
|
+
log.info({ event: "closing_text_continuation_injected" }, "turn ended on a tool call with no visible reply; injecting a closing-text continuation");
|
|
2749
|
+
await session.sendCustomMessage({
|
|
2750
|
+
customType: "closing-text-nudge",
|
|
2751
|
+
content: CLOSING_TEXT_NUDGE,
|
|
2752
|
+
display: false,
|
|
2753
|
+
details: {}
|
|
2754
|
+
}, { triggerTurn: true });
|
|
2755
|
+
}
|
|
2756
|
+
//#endregion
|
|
2757
|
+
//#region src/tracing.ts
|
|
2758
|
+
const SERVICE_NAME = "skydive-agent-harness";
|
|
2759
|
+
const DAEMON_TRACES_URL = "http://localhost:38994/v1/traces";
|
|
2760
|
+
let provider = null;
|
|
2761
|
+
function initTracing() {
|
|
2762
|
+
if (provider) return;
|
|
2763
|
+
propagation.setGlobalPropagator(new W3CTraceContextPropagator());
|
|
2764
|
+
provider = new NodeTracerProvider({ resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME }) });
|
|
2765
|
+
const exporter = new OTLPTraceExporter({ url: DAEMON_TRACES_URL });
|
|
2766
|
+
provider.addSpanProcessor(new BatchSpanProcessor(exporter, { scheduledDelayMillis: 1e3 }));
|
|
2767
|
+
provider.register();
|
|
2768
|
+
logger.info({
|
|
2769
|
+
event: "tracing_enabled",
|
|
2770
|
+
endpoint: DAEMON_TRACES_URL
|
|
2771
|
+
}, "OTel tracing initialized — exporting via daemon");
|
|
2772
|
+
const shutdown = async () => {
|
|
2773
|
+
await shutdownTracing();
|
|
2774
|
+
process.exit(0);
|
|
2775
|
+
};
|
|
2776
|
+
process.on("SIGTERM", shutdown);
|
|
2777
|
+
process.on("SIGINT", shutdown);
|
|
2778
|
+
}
|
|
2779
|
+
function getTracer() {
|
|
2780
|
+
return trace.getTracer(SERVICE_NAME);
|
|
2781
|
+
}
|
|
2782
|
+
function extractRemoteContext() {
|
|
2783
|
+
const traceparent = getCurrentTraceparent();
|
|
2784
|
+
if (!traceparent) return ROOT_CONTEXT;
|
|
2785
|
+
const carrier = { traceparent };
|
|
2786
|
+
return propagation.extract(ROOT_CONTEXT, carrier, {
|
|
2787
|
+
get: (c, key) => c[key],
|
|
2788
|
+
keys: (c) => Object.keys(c)
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
async function shutdownTracing() {
|
|
2792
|
+
if (provider) await provider.shutdown();
|
|
2793
|
+
}
|
|
2794
|
+
//#endregion
|
|
2795
|
+
//#region src/harness.ts
|
|
2796
|
+
/**
|
|
2797
|
+
* Skydive composition over @skydiveai/pi-server: wires the platform
|
|
2798
|
+
* defaults (tracing to the daemon, tool-update hot-reload hooks, prewarm
|
|
2799
|
+
* paths, header passthrough for proxy routing, Skydive agent-card
|
|
2800
|
+
* branding) into the generic protocol server, and returns mountable
|
|
2801
|
+
* handlers. The agent supplies the pi session factory (their session.ts)
|
|
2802
|
+
* and owns the express app:
|
|
2803
|
+
*
|
|
2804
|
+
* const { platform, protocols } = createHarness({
|
|
2805
|
+
* cwd: process.cwd(),
|
|
2806
|
+
* createSession,
|
|
2807
|
+
* });
|
|
2808
|
+
* app.get('/health', platform.handlers.health);
|
|
2809
|
+
* app.use(platform.handlers.injectEnv);
|
|
2810
|
+
* app.use(platform.handlers.prewarm); // matches /_skydive/prewarm + legacy alias
|
|
2811
|
+
* app.use(protocols.handlers.all);
|
|
2812
|
+
*/
|
|
2813
|
+
const A2A_PATH = "/a2a";
|
|
2814
|
+
const AGENT_CARD_PATH = "/.well-known/agent-card.json";
|
|
2815
|
+
const PREWARM_PATHS = ["/_skydive/prewarm", "/_anyone/prewarm"];
|
|
2816
|
+
const PASSTHROUGH_HEADER_PREFIXES = ["x-anyone-", "x-skydive-"];
|
|
2817
|
+
function createHarness(options) {
|
|
2818
|
+
initTracing();
|
|
2819
|
+
startFeatureFlagPoller();
|
|
2820
|
+
readPlatformVersions().then((runtimeVersions) => logger.info({
|
|
2821
|
+
event: "runtime_versions",
|
|
2822
|
+
runtimeVersions
|
|
2823
|
+
}, "platform runtime versions"));
|
|
2824
|
+
const serverOptions = {
|
|
2825
|
+
...options,
|
|
2826
|
+
onSessionSetup: options.onSessionSetup ?? installToolUpdateAutoStop,
|
|
2827
|
+
postPrompt: options.postPrompt ?? (async (args) => {
|
|
2828
|
+
await runToolUpdateLoop(args);
|
|
2829
|
+
await runClosingTextLoop(args);
|
|
2830
|
+
}),
|
|
2831
|
+
passthroughHeaderPrefixes: options.passthroughHeaderPrefixes ?? PASSTHROUGH_HEADER_PREFIXES,
|
|
2832
|
+
prewarmPaths: options.prewarmPaths ?? PREWARM_PATHS
|
|
2833
|
+
};
|
|
2834
|
+
const webHandlers = createProtocolHandlers(serverOptions);
|
|
2835
|
+
const cardOverrides = {
|
|
2836
|
+
name: "Skydive Agent",
|
|
2837
|
+
description: "An AI coding agent powered by the Skydive platform.",
|
|
2838
|
+
...options.agentCard
|
|
2839
|
+
};
|
|
2840
|
+
const agentCardWeb = async (request) => {
|
|
2841
|
+
const url = new URL(request.url);
|
|
2842
|
+
if (request.method !== "GET" || url.pathname !== AGENT_CARD_PATH) return null;
|
|
2843
|
+
return Response.json(buildAgentCard(cardOverrides.url ?? `${url.origin}${A2A_PATH}`, cardOverrides));
|
|
2844
|
+
};
|
|
2845
|
+
const a2a = restHandler({
|
|
2846
|
+
requestHandler: new DefaultRequestHandler(buildAgentCard(cardOverrides.url ?? A2A_PATH, cardOverrides), new InMemoryTaskStore(), createAgentExecutor(serverOptions), new DefaultExecutionEventBusManager()),
|
|
2847
|
+
userBuilder: UserBuilder.noAuthentication
|
|
2848
|
+
});
|
|
2849
|
+
return {
|
|
2850
|
+
platform: { handlers: {
|
|
2851
|
+
/**
|
|
2852
|
+
* Platform health plus the agent's `healthMetadata`. Mount above
|
|
2853
|
+
* injectEnv — health must respond immediately for prewarm
|
|
2854
|
+
* stashing and readiness probes, and injectEnv can wait up to
|
|
2855
|
+
* 10s for env vars during boot.
|
|
2856
|
+
*/
|
|
2857
|
+
health: createHealthHandler({ metadata: options.healthMetadata ?? null }),
|
|
2858
|
+
/** Loads platform env (e2b envd / daemon long-poll). */
|
|
2859
|
+
injectEnv: createPlatformEnvMiddleware(),
|
|
2860
|
+
prewarm: webHandlerToMiddleware(webHandlers.prewarm)
|
|
2861
|
+
} },
|
|
2862
|
+
protocols: { handlers: {
|
|
2863
|
+
/** Express-style; mount at /a2a. */
|
|
2864
|
+
a2a,
|
|
2865
|
+
/** GET /.well-known/agent-card.json. */
|
|
2866
|
+
agentCard: webHandlerToMiddleware(agentCardWeb),
|
|
2867
|
+
/** Mirrors each vendor's API shape. */
|
|
2868
|
+
openai: { v1: {
|
|
2869
|
+
chat: { completions: webHandlerToMiddleware(webHandlers.chatCompletions) },
|
|
2870
|
+
responses: webHandlerToMiddleware(webHandlers.responses)
|
|
2871
|
+
} },
|
|
2872
|
+
anthropic: { v1: { messages: webHandlerToMiddleware(webHandlers.messages) } },
|
|
2873
|
+
/**
|
|
2874
|
+
* Everything in one mount: a2a (+ agent card) at their well-known
|
|
2875
|
+
* paths, then chat-completions / anthropic-messages / responses.
|
|
2876
|
+
* Calls next() when nothing matches.
|
|
2877
|
+
*/
|
|
2878
|
+
all: chainMiddleware([mountAt(A2A_PATH, a2a), webHandlerToMiddleware(composeHandlers([
|
|
2879
|
+
agentCardWeb,
|
|
2880
|
+
webHandlers.chatCompletions,
|
|
2881
|
+
webHandlers.messages,
|
|
2882
|
+
webHandlers.responses
|
|
2883
|
+
]))])
|
|
2884
|
+
} }
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
/** Effective bash timeout: the model's value when it gave a positive number, else the default. */
|
|
2888
|
+
function resolveBashTimeout(provided) {
|
|
2889
|
+
return typeof provided === "number" && provided > 0 ? provided : 600;
|
|
2890
|
+
}
|
|
2891
|
+
const bashDefaultTimeoutExtension = (pi) => {
|
|
2892
|
+
pi.on("tool_call", async (event) => {
|
|
2893
|
+
if (event.toolName !== "bash") return;
|
|
2894
|
+
event.input.timeout = resolveBashTimeout(event.input.timeout);
|
|
2895
|
+
});
|
|
2896
|
+
};
|
|
2897
|
+
//#endregion
|
|
2898
|
+
//#region src/extensions/resource-pressure-warning.ts
|
|
1920
2899
|
/**
|
|
1921
|
-
*
|
|
2900
|
+
* Mid-run resource-pressure warning to the agent.
|
|
1922
2901
|
*
|
|
1923
|
-
* The
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
2902
|
+
* The sandbox already detects pressure — the boot scripts cap the
|
|
2903
|
+
* user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
|
|
2904
|
+
* edges for memory and disk — but nothing told the *agent*, so a turn burned
|
|
2905
|
+
* straight to the OOM kill (or a full disk) and only learned about it from
|
|
2906
|
+
* the post-mortem notice. This extension closes that gap in-process: while a
|
|
2907
|
+
* turn is active it polls the agent cgroup and the root filesystem and, the
|
|
2908
|
+
* first time usage crosses a warn threshold, folds a system notification into
|
|
2909
|
+
* the open turn so the agent can checkpoint, shed work (constrain
|
|
2910
|
+
* parallelism, kill a background hog, clean scratch space), or request a
|
|
2911
|
+
* bigger tier BEFORE the kill. The warning is model-facing only; the
|
|
2912
|
+
* agent must not narrate it to the user unless asked.
|
|
1929
2913
|
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
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.
|
|
2914
|
+
* The notification is triggered by the two conditions that actually kill
|
|
2915
|
+
* work — memory near the cgroup hard cap, disk near full — and reports a
|
|
2916
|
+
* snapshot of all the relevant stats (memory, CPU utilization, disk) so the
|
|
2917
|
+
* agent can tell which resource is the problem and how much headroom the
|
|
2918
|
+
* others have.
|
|
1938
2919
|
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
1941
|
-
*
|
|
2920
|
+
* Edge-triggered, once per trigger per turn: the fired flags reset on
|
|
2921
|
+
* agent_start, so a turn that rides a threshold gets one warning per
|
|
2922
|
+
* resource, not a stream. Polling only runs while the agent is active — an
|
|
2923
|
+
* idle sandbox's resource usage is not the agent's problem and there is no
|
|
2924
|
+
* open turn to deliver into anyway.
|
|
2925
|
+
*
|
|
2926
|
+
* Best-effort throughout: any read failure (cgroup absent, controller not
|
|
2927
|
+
* delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
|
|
2928
|
+
* stat and the extension warns on what it can see — it must never break a
|
|
2929
|
+
* turn over an observability feature.
|
|
1942
2930
|
*/
|
|
1943
|
-
const
|
|
1944
|
-
const
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
function
|
|
1954
|
-
return
|
|
2931
|
+
const execFileAsync = promisify(execFile);
|
|
2932
|
+
const log$9 = logger.child({ module: "resource-pressure-warning" });
|
|
2933
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
2934
|
+
function envOverride(name) {
|
|
2935
|
+
for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
|
|
2936
|
+
const value = process.env[`${prefix}${name}`];
|
|
2937
|
+
if (value != null && value !== "") return value;
|
|
2938
|
+
}
|
|
2939
|
+
return null;
|
|
2940
|
+
}
|
|
2941
|
+
function cgroupDir() {
|
|
2942
|
+
return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
|
|
2943
|
+
}
|
|
2944
|
+
function diskRoot() {
|
|
2945
|
+
return envOverride("DISK_ROOT") ?? "/";
|
|
1955
2946
|
}
|
|
1956
2947
|
/**
|
|
1957
|
-
*
|
|
1958
|
-
* (
|
|
1959
|
-
*
|
|
2948
|
+
* Read a cgroup v2 scalar file. Returns a number, or null for "max"
|
|
2949
|
+
* (uncapped), an empty/absent file, or any read/parse error — an uncapped or
|
|
2950
|
+
* unreadable limit means there is nothing meaningful to warn against.
|
|
1960
2951
|
*/
|
|
1961
|
-
function
|
|
1962
|
-
|
|
1963
|
-
|
|
2952
|
+
async function readScalar(file) {
|
|
2953
|
+
try {
|
|
2954
|
+
const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
|
|
2955
|
+
if (raw === "" || raw === "max") return null;
|
|
2956
|
+
const n = Number(raw);
|
|
2957
|
+
return Number.isFinite(n) ? n : null;
|
|
2958
|
+
} catch (_error) {
|
|
2959
|
+
return null;
|
|
2960
|
+
}
|
|
1964
2961
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2962
|
+
/**
|
|
2963
|
+
* Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
|
|
2964
|
+
* cpu.stat) and return the counter for `key`, or null when absent.
|
|
2965
|
+
*/
|
|
2966
|
+
async function readKeyedCounter(file, key) {
|
|
2967
|
+
try {
|
|
2968
|
+
const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
|
|
2969
|
+
for (const line of raw.split("\n")) {
|
|
2970
|
+
const [k, v] = line.trim().split(/\s+/);
|
|
2971
|
+
if (k === key) {
|
|
2972
|
+
const n = Number(v);
|
|
2973
|
+
return Number.isFinite(n) ? n : null;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
return null;
|
|
2977
|
+
} catch (_error) {
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* Live memory usage as an integer percent of the hard cap, or null when
|
|
2983
|
+
* either side is unreadable/uncapped. Exported for tests.
|
|
2984
|
+
*/
|
|
2985
|
+
async function readMemUsePct() {
|
|
2986
|
+
const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
|
|
2987
|
+
if (current === null || max === null || max <= 0) return null;
|
|
2988
|
+
return {
|
|
2989
|
+
pct: Math.floor(current / max * 100),
|
|
2990
|
+
currentBytes: current,
|
|
2991
|
+
maxBytes: max
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* Root filesystem used% (df -P Capacity column), or null on any failure.
|
|
2996
|
+
* Exported for tests.
|
|
2997
|
+
*/
|
|
2998
|
+
async function readDiskUsePct() {
|
|
2999
|
+
try {
|
|
3000
|
+
const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
|
|
3001
|
+
const dataRow = stdout.trim().split("\n")[1];
|
|
3002
|
+
if (dataRow == null) return null;
|
|
3003
|
+
const capacity = dataRow.trim().split(/\s+/)[4];
|
|
3004
|
+
if (capacity == null) return null;
|
|
3005
|
+
const pct = Number(capacity.replace("%", ""));
|
|
3006
|
+
return Number.isFinite(pct) ? pct : null;
|
|
3007
|
+
} catch (_error) {
|
|
3008
|
+
return null;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
/**
|
|
3012
|
+
* CPU utilization sampler. cgroup v2 exposes cumulative CPU time
|
|
3013
|
+
* (cpu.stat usage_usec); utilization is the delta between two samples over
|
|
3014
|
+
* the wall time between them, normalized by core count. The first call after
|
|
3015
|
+
* construction has no previous sample and returns null.
|
|
3016
|
+
*/
|
|
3017
|
+
function createCpuSampler() {
|
|
3018
|
+
let prevUsageUsec = null;
|
|
3019
|
+
let prevAtMs = null;
|
|
3020
|
+
return async () => {
|
|
3021
|
+
const usage = await readKeyedCounter("cpu.stat", "usage_usec");
|
|
3022
|
+
const now = Date.now();
|
|
3023
|
+
const prev = prevUsageUsec;
|
|
3024
|
+
const prevAt = prevAtMs;
|
|
3025
|
+
prevUsageUsec = usage;
|
|
3026
|
+
prevAtMs = now;
|
|
3027
|
+
if (usage === null || prev === null || prevAt === null) return null;
|
|
3028
|
+
const wallUsec = (now - prevAt) * 1e3;
|
|
3029
|
+
if (wallUsec <= 0) return null;
|
|
3030
|
+
const cores = availableParallelism();
|
|
3031
|
+
const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
|
|
3032
|
+
return Math.max(0, Math.min(100, pct));
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
function fmtMb(bytes) {
|
|
3036
|
+
return Math.round(bytes / 1024 / 1024);
|
|
3037
|
+
}
|
|
3038
|
+
/** The model-facing warning text. Exported for tests. */
|
|
3039
|
+
function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
|
|
3040
|
+
const stats = [];
|
|
3041
|
+
if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
|
|
3042
|
+
if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
|
|
3043
|
+
if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
|
|
3044
|
+
const lead = trigger === "memory" ? `Your sandbox is at ${mem?.pct}% of its memory cap. If usage keeps climbing, the kernel will kill the offending process and this turn may die with it.` : `Your sandbox's disk is ${diskPct}% full. If it fills completely, writes will start failing and this turn may die with them.`;
|
|
3045
|
+
const remedy = trigger === "memory" ? "checkpoint in-flight work (commit and push), then reduce the footprint — constrain parallelism, run heavy steps sequentially, or kill background processes you no longer need." : "checkpoint in-flight work (commit and push), then free space — clean build artifacts, caches, and scratch files you no longer need.";
|
|
3046
|
+
const closer = trigger === "memory" ? "If the workload genuinely needs more memory or CPU, request a bigger sandbox with `platform compute request`." : "A bigger compute tier will not add disk. I cannot add disk from this box, and a replacement sandbox on the same template has the same disk.";
|
|
3047
|
+
const jargon = trigger === "memory" ? "\"I freed up memory on my computer\" or \"I'll probably need a bigger computer with more memory\", not \"I killed the leftover Chromium render so we don't get OOM'd\". Instead, just request the resources you need or change how you work on the task to succeed within this budget." : "\"I freed up disk on my computer\", not \"the root filesystem is at capacity\". Change how you work on the task to succeed within this budget. Do not request a compute upgrade for disk pressure.";
|
|
3048
|
+
return `<system_notification>${lead} Current usage: ${stats.join(", ")}. Act now: ${remedy} ${closer} Do not mention this constraint as you work unless the user asks. No jargon: ${jargon} This is an automated resource warning, not a message from the user; continue the task, adjusted.</system_notification>`;
|
|
3049
|
+
}
|
|
3050
|
+
const resourcePressureWarningExtension = (pi) => {
|
|
3051
|
+
let agentActive = false;
|
|
3052
|
+
let warnedMemThisTurn = false;
|
|
3053
|
+
let warnedDiskThisTurn = false;
|
|
3054
|
+
let timer = null;
|
|
3055
|
+
const sampleCpu = createCpuSampler();
|
|
3056
|
+
async function checkOnce() {
|
|
3057
|
+
if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
|
|
3058
|
+
const [mem, cpuPct, diskPct] = await Promise.all([
|
|
3059
|
+
readMemUsePct(),
|
|
3060
|
+
sampleCpu(),
|
|
3061
|
+
readDiskUsePct()
|
|
3062
|
+
]);
|
|
3063
|
+
let trigger = null;
|
|
3064
|
+
if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
|
|
3065
|
+
trigger = "memory";
|
|
3066
|
+
warnedMemThisTurn = true;
|
|
3067
|
+
} else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
|
|
3068
|
+
trigger = "disk";
|
|
3069
|
+
warnedDiskThisTurn = true;
|
|
3070
|
+
}
|
|
3071
|
+
if (trigger === null) return;
|
|
1973
3072
|
log$9.warn({
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
3073
|
+
trigger,
|
|
3074
|
+
mem,
|
|
3075
|
+
cpuPct,
|
|
3076
|
+
diskPct
|
|
3077
|
+
}, "resource pressure warning delivered to agent");
|
|
3078
|
+
await pi.sendMessage({
|
|
3079
|
+
customType: "anyone-resource-pressure-warning",
|
|
3080
|
+
content: resourcePressureWarningText(trigger, {
|
|
3081
|
+
mem,
|
|
3082
|
+
cpuPct,
|
|
3083
|
+
diskPct
|
|
3084
|
+
}),
|
|
3085
|
+
display: false
|
|
3086
|
+
}, {
|
|
3087
|
+
triggerTurn: true,
|
|
3088
|
+
deliverAs: "followUp"
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
pi.on("agent_start", async () => {
|
|
3092
|
+
agentActive = true;
|
|
3093
|
+
warnedMemThisTurn = false;
|
|
3094
|
+
warnedDiskThisTurn = false;
|
|
3095
|
+
if (!timer) {
|
|
3096
|
+
timer = setInterval(() => {
|
|
3097
|
+
checkOnce().catch((err) => {
|
|
3098
|
+
log$9.error({ err }, "resource pressure check failed");
|
|
3099
|
+
});
|
|
3100
|
+
}, POLL_INTERVAL_MS);
|
|
3101
|
+
timer.unref?.();
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
pi.on("agent_end", async () => {
|
|
3105
|
+
agentActive = false;
|
|
3106
|
+
if (timer) {
|
|
3107
|
+
clearInterval(timer);
|
|
3108
|
+
timer = null;
|
|
3109
|
+
}
|
|
3110
|
+
});
|
|
3111
|
+
};
|
|
3112
|
+
//#endregion
|
|
3113
|
+
//#region src/extensions/disk-guard.ts
|
|
3114
|
+
const log$8 = logger.child({ module: "disk-guard" });
|
|
3115
|
+
/**
|
|
3116
|
+
* In-band bypass. The guard is a safety net, not a jail: when the agent knows
|
|
3117
|
+
* a flagged command is genuinely safe (writing to a different mount, a tiny
|
|
3118
|
+
* bounded download, a delete-then-clone one-liner, an emergency it accepts the
|
|
3119
|
+
* risk on) it can force the command through by appending this marker as a
|
|
3120
|
+
* trailing shell comment. Kept as a comment so it never changes what the
|
|
3121
|
+
* command does, and matched case-insensitively with flexible spacing so the
|
|
3122
|
+
* agent doesn't have to reproduce it byte-for-byte.
|
|
3123
|
+
*/
|
|
3124
|
+
const BYPASS_MARKER = /#\s*disk-guard:\s*allow\b/i;
|
|
3125
|
+
/** The exact marker text the block message tells the agent to append. */
|
|
3126
|
+
const BYPASS_HINT = "# disk-guard: allow";
|
|
3127
|
+
/**
|
|
3128
|
+
* Harness-level kill switch: set DISK_GUARD_DISABLE=1 to turn the guard off
|
|
3129
|
+
* entirely. This is the "I own my harness, let me opt out" knob — an agent
|
|
3130
|
+
* that boots its own harness can disable the guard for its whole process
|
|
3131
|
+
* without a code roll, and it's also the fleet-wide escape hatch if the
|
|
3132
|
+
* classifier ever misfires and blocks real work. The bare name is honored
|
|
3133
|
+
* first; the SKYDIVE_/ANYONE_ prefixes are accepted too for consistency with
|
|
3134
|
+
* the other env overrides. Empty/unset/"0"/"false" leave the guard on.
|
|
3135
|
+
*/
|
|
3136
|
+
function guardDisabledByEnv() {
|
|
3137
|
+
for (const name of [
|
|
3138
|
+
"DISK_GUARD_DISABLE",
|
|
3139
|
+
"SKYDIVE_DISK_GUARD_DISABLE",
|
|
3140
|
+
"ANYONE_DISK_GUARD_DISABLE"
|
|
3141
|
+
]) {
|
|
3142
|
+
const value = process.env[name];
|
|
3143
|
+
if (value != null && value !== "" && value !== "0" && value !== "false") return true;
|
|
1977
3144
|
}
|
|
3145
|
+
return false;
|
|
1978
3146
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
apply("contextManagement", flags.contextManagement ?? null);
|
|
1983
|
-
apply("subagent", flags.subagent ?? null);
|
|
3147
|
+
/** True when the command carries the in-band bypass marker. */
|
|
3148
|
+
function hasBypassMarker(command) {
|
|
3149
|
+
return BYPASS_MARKER.test(command);
|
|
1984
3150
|
}
|
|
1985
3151
|
/**
|
|
1986
|
-
*
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
3152
|
+
* Commands that reclaim space or merely inspect it. If any of these verbs
|
|
3153
|
+
* appears in the command line, we never block — otherwise the guard would trap
|
|
3154
|
+
* the agent by blocking the exact command it needs to dig out. Matched as
|
|
3155
|
+
* whole words so `remove-item` etc. don't accidentally match `rm`.
|
|
1990
3156
|
*/
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
3157
|
+
const RECLAIM_PATTERNS = [
|
|
3158
|
+
/\brm\b/,
|
|
3159
|
+
/\brmdir\b/,
|
|
3160
|
+
/\bdf\b/,
|
|
3161
|
+
/\bdu\b/,
|
|
3162
|
+
/\bncdu\b/,
|
|
3163
|
+
/\bfind\b[^|]*\s-delete\b/,
|
|
3164
|
+
/\btruncate\b/,
|
|
3165
|
+
/\bgit\s+(gc|prune|clean|worktree\s+remove|worktree\s+prune)\b/,
|
|
3166
|
+
/\b(yarn|npm|pnpm|bun)\s+.*\b(cache\s+clean|cache\s+clear|store\s+prune)\b/,
|
|
3167
|
+
/\bcache\s+(clean|clear|prune)\b/,
|
|
3168
|
+
/\b(docker|podman)\s+.*\bprune\b/,
|
|
3169
|
+
/\bapt(-get)?\s+clean\b/,
|
|
3170
|
+
/\bjournalctl\b[^|]*--vacuum/
|
|
3171
|
+
];
|
|
3172
|
+
/**
|
|
3173
|
+
* File extensions that mean a download is actually LARGE — archives, disk
|
|
3174
|
+
* images, compiled/binary artifacts, model weights, media. A curl/wget is only
|
|
3175
|
+
* gated when it writes one of these; an API/page fetch to a `.json`/`.html`/
|
|
3176
|
+
* `.txt` file is tiny and must not be blocked. Derived from 4,144 real
|
|
3177
|
+
* commands: ~64% of `curl -o` uses were tiny fetches, only ~4% large.
|
|
3178
|
+
*/
|
|
3179
|
+
const BIG_DOWNLOAD_EXT = "(?:tar\\.gz|tgz|tar|zip|iso|gz|bz2|xz|zst|deb|rpm|pkg|dmg|whl|jar|7z|img|mp4|mov|avi|mkv|onnx|gguf|safetensors|bin|node)";
|
|
3180
|
+
/**
|
|
3181
|
+
* Commands that consume a meaningful amount of disk. Kept deliberately tight
|
|
3182
|
+
* and high-precision: validated against 4,144 real commands from the last 7
|
|
3183
|
+
* days, the earlier "writes a file" heuristic flagged 82% of everything (a
|
|
3184
|
+
* `curl -o /tmp/x.json` API call is not a disk event). This set flags ~33%,
|
|
3185
|
+
* almost all genuinely large — real installs, clones, big-archive downloads,
|
|
3186
|
+
* extractions. What was DROPPED and why:
|
|
3187
|
+
* - `git fetch` / `git pull` — incremental on an existing clone, usually tiny.
|
|
3188
|
+
* - `git checkout` — overwhelmingly `git checkout <ref> -- <file>` or a
|
|
3189
|
+
* branch switch, ~zero net growth; the rare full materialization isn't
|
|
3190
|
+
* worth the false-positive rate.
|
|
3191
|
+
* - bare `curl -o` / `wget -o` — see BIG_DOWNLOAD_EXT above.
|
|
3192
|
+
* - loose `… build` — matched `--mode=skip-build`, `oxfmt … build`, prose.
|
|
3193
|
+
* The remaining big-disk op in escher is `git clone` and `git worktree add`
|
|
3194
|
+
* (which is really a checkout), both kept.
|
|
3195
|
+
*/
|
|
3196
|
+
const SPACE_HUNGRY_PATTERNS = [
|
|
3197
|
+
/\bgit\s+clone\b/,
|
|
3198
|
+
/\bgit\s+worktree\s+add\b/,
|
|
3199
|
+
/\b(yarn|npm|pnpm|bun)\s+(install|add|ci)\b/,
|
|
3200
|
+
/\byarn\s*$/,
|
|
3201
|
+
/\byarn\s+--(?!version|help)\S/,
|
|
3202
|
+
/\bpip3?\s+install\b/,
|
|
3203
|
+
/\bapt(-get)?\s+install\b/,
|
|
3204
|
+
/\bnpm\s+pack\b/,
|
|
3205
|
+
/\bdocker\s+(build|pull)\b/,
|
|
3206
|
+
new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b`, "i"),
|
|
3207
|
+
new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b`, "i"),
|
|
3208
|
+
/\btar\s+[^\n|]*x[^\n|]*f/,
|
|
3209
|
+
/\bunzip\b/,
|
|
3210
|
+
/\bdd\b[^\n|]*\bof=/
|
|
3211
|
+
];
|
|
3212
|
+
/**
|
|
3213
|
+
* True when the command reclaims or inspects space — these are always allowed,
|
|
3214
|
+
* even on a 100%-full box, so the agent can dig itself out.
|
|
3215
|
+
*/
|
|
3216
|
+
function isReclaimCommand(command) {
|
|
3217
|
+
return RECLAIM_PATTERNS.some((re) => re.test(command));
|
|
3218
|
+
}
|
|
3219
|
+
/**
|
|
3220
|
+
* True when the command is likely to consume a meaningful amount of disk.
|
|
3221
|
+
* A reclaim/inspect command is never space-hungry — the reclaim check wins so a
|
|
3222
|
+
* `git worktree remove` or a `yarn cache clean` is never mistaken for growth.
|
|
3223
|
+
*/
|
|
3224
|
+
function isSpaceHungryCommand(command) {
|
|
3225
|
+
if (isReclaimCommand(command)) return false;
|
|
3226
|
+
return SPACE_HUNGRY_PATTERNS.some((re) => re.test(command));
|
|
3227
|
+
}
|
|
3228
|
+
/**
|
|
3229
|
+
* The decision, factored out and pure so it's exhaustively testable without a
|
|
3230
|
+
* real filesystem. Block only when we have a disk reading, it's at/above the
|
|
3231
|
+
* critical threshold, the command is space-hungry (and not a reclaim), and the
|
|
3232
|
+
* agent hasn't explicitly opted out with the bypass marker.
|
|
3233
|
+
*/
|
|
3234
|
+
function shouldBlockForDisk(command, diskPct) {
|
|
3235
|
+
if (diskPct === null) return false;
|
|
3236
|
+
if (diskPct < 95) return false;
|
|
3237
|
+
if (hasBypassMarker(command)) return false;
|
|
3238
|
+
return isSpaceHungryCommand(command);
|
|
3239
|
+
}
|
|
3240
|
+
/** The agent-facing explanation returned as the blocked tool result. */
|
|
3241
|
+
function diskBlockReason(command, diskPct) {
|
|
3242
|
+
return `Blocked: the sandbox disk is ${diskPct}% full and this command (\`${command.trim().slice(0, 120)}\`) writes a large amount, so it would fail partway with ENOSPC and leave a corrupt result. Reclaim space FIRST, then retry. Free ONLY what THIS conversation created — scratch/build output you wrote this run, downloads you're done with, and worktrees/branches whose work you've already committed and pushed (\`git worktree remove\`, \`yarn cache clean\`, delete your own scratch). Do NOT blindly wipe /tmp or delete a clone/worktree you don't recognize — other conversations share this box. Check headroom with \`df -h /\` and \`du -sh ~/workspace/* 2>/dev/null\`. If you genuinely can't free enough, stop and tell the user you're blocked on disk rather than retrying the write. If you're certain this command is safe anyway (writes elsewhere, tiny bounded size, delete-then-write), force it through by appending \` ${BYPASS_HINT}\` to the command.`;
|
|
1996
3243
|
}
|
|
3244
|
+
const diskGuardExtension = (pi) => {
|
|
3245
|
+
pi.on("tool_call", async (event) => {
|
|
3246
|
+
if (event.toolName !== "bash") return;
|
|
3247
|
+
if (guardDisabledByEnv()) return;
|
|
3248
|
+
const command = event.input.command;
|
|
3249
|
+
if (typeof command !== "string" || command.length === 0) return;
|
|
3250
|
+
if (hasBypassMarker(command)) return;
|
|
3251
|
+
if (!isSpaceHungryCommand(command)) return;
|
|
3252
|
+
const diskPct = await readDiskUsePct();
|
|
3253
|
+
if (!shouldBlockForDisk(command, diskPct)) return;
|
|
3254
|
+
log$8.warn({
|
|
3255
|
+
diskPct,
|
|
3256
|
+
command: command.slice(0, 200)
|
|
3257
|
+
}, "blocked space-hungry bash command on near-full disk");
|
|
3258
|
+
return {
|
|
3259
|
+
block: true,
|
|
3260
|
+
reason: diskBlockReason(command, diskPct)
|
|
3261
|
+
};
|
|
3262
|
+
});
|
|
3263
|
+
};
|
|
1997
3264
|
//#endregion
|
|
1998
3265
|
//#region src/extensions/context-management-trim.ts
|
|
1999
3266
|
const CLEARED_PLACEHOLDER = "[old tool result cleared to save context — re-run the tool or re-read the source to recover it]";
|
|
@@ -2086,7 +3353,7 @@ function transformContextMessages(messages, config, now) {
|
|
|
2086
3353
|
}
|
|
2087
3354
|
//#endregion
|
|
2088
3355
|
//#region src/extensions/context-management.ts
|
|
2089
|
-
const log$
|
|
3356
|
+
const log$7 = logger.child({ module: "context-management-extension" });
|
|
2090
3357
|
function isAnthropicMessagesPayload(payload) {
|
|
2091
3358
|
if (typeof payload !== "object" || payload === null) return false;
|
|
2092
3359
|
const candidate = payload;
|
|
@@ -2151,13 +3418,13 @@ function createContextManagementExtension() {
|
|
|
2151
3418
|
setContextManagementFlagOverride(getPolledFlag("contextManagement"));
|
|
2152
3419
|
onFlagChange("contextManagement", (enabled) => {
|
|
2153
3420
|
setContextManagementFlagOverride(enabled);
|
|
2154
|
-
log$
|
|
3421
|
+
log$7.info({
|
|
2155
3422
|
event: "context_management_flag_update",
|
|
2156
3423
|
enabled
|
|
2157
3424
|
}, "context-management flag updated from platform");
|
|
2158
3425
|
});
|
|
2159
3426
|
startFeatureFlagPoller();
|
|
2160
|
-
log$
|
|
3427
|
+
log$7.info({
|
|
2161
3428
|
event: "context_management_registered",
|
|
2162
3429
|
enabled: initial.enabled,
|
|
2163
3430
|
flagSource: hasFlagSource(),
|
|
@@ -2169,13 +3436,13 @@ function createContextManagementExtension() {
|
|
|
2169
3436
|
const { messages } = event;
|
|
2170
3437
|
try {
|
|
2171
3438
|
const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
|
|
2172
|
-
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$
|
|
3439
|
+
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$7.info({
|
|
2173
3440
|
event: "context_management_applied",
|
|
2174
3441
|
...result.stats
|
|
2175
3442
|
}, "trimmed/cleared tool output before LLM call");
|
|
2176
3443
|
return { messages: result.messages };
|
|
2177
3444
|
} catch (err) {
|
|
2178
|
-
log$
|
|
3445
|
+
log$7.error({
|
|
2179
3446
|
err,
|
|
2180
3447
|
event: "context_management_transform_failed"
|
|
2181
3448
|
}, "context transform failed; passing messages through unchanged");
|
|
@@ -2187,7 +3454,7 @@ function createContextManagementExtension() {
|
|
|
2187
3454
|
}
|
|
2188
3455
|
//#endregion
|
|
2189
3456
|
//#region src/extensions/current-time.ts
|
|
2190
|
-
const log$
|
|
3457
|
+
const log$6 = logger.child({ module: "current-time-extension" });
|
|
2191
3458
|
const PI_DATE_LINE = /^Current date:.*$/m;
|
|
2192
3459
|
function formatCurrentTimeLine(now) {
|
|
2193
3460
|
return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
|
|
@@ -2200,7 +3467,7 @@ const currentTimeExtension = (pi) => {
|
|
|
2200
3467
|
const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
|
|
2201
3468
|
const base = event.systemPrompt;
|
|
2202
3469
|
if (PI_DATE_LINE.test(base)) {
|
|
2203
|
-
log$
|
|
3470
|
+
log$6.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
3471
|
return { systemPrompt: base.replace(PI_DATE_LINE, line) };
|
|
2205
3472
|
}
|
|
2206
3473
|
return { systemPrompt: `${base}\n${line}` };
|
|
@@ -2209,37 +3476,35 @@ const currentTimeExtension = (pi) => {
|
|
|
2209
3476
|
//#endregion
|
|
2210
3477
|
//#region src/memory.ts
|
|
2211
3478
|
/**
|
|
2212
|
-
* In-harness memory
|
|
3479
|
+
* In-harness memory readers.
|
|
2213
3480
|
*
|
|
2214
|
-
* The agent has an agent-level file-based memory at `<cwd>/.memory/`,
|
|
2215
|
-
*
|
|
3481
|
+
* The agent has an agent-level file-based memory at `<cwd>/.memory/`, split
|
|
3482
|
+
* into two halves that are surfaced differently:
|
|
2216
3483
|
*
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
3484
|
+
* 1. Shared knowledge (projects, lessons, external systems) — indexed by a
|
|
3485
|
+
* single hand-maintained `<cwd>/.memory/MEMORY.md` that the *agent* writes
|
|
3486
|
+
* and curates, Claude-Code style: one line per fact pointing at the file
|
|
3487
|
+
* that holds it. `readMemoryIndexFile` just reads that file; the agent owns
|
|
3488
|
+
* its contents. This is the whole index for the shared half — there is no
|
|
3489
|
+
* derived walk and no per-directory `MEMORY.md`.
|
|
2221
3490
|
*
|
|
2222
|
-
*
|
|
2223
|
-
*
|
|
2224
|
-
*
|
|
3491
|
+
* 2. Per-person memory — `.memory/users/<id>-<name>/<topic>.md`. This half is
|
|
3492
|
+
* *derived*, not hand-maintained, because it has to be filtered to the one
|
|
3493
|
+
* person on the current turn (a single hand-written index couldn't be
|
|
3494
|
+
* scoped per-user without leaking one person's notes into another's
|
|
3495
|
+
* conversation). `buildMemoryIndex` walks a single user's directory, reads
|
|
3496
|
+
* only the frontmatter of each `.md` (open fd → read first ~4KB → close, in
|
|
3497
|
+
* parallel), and renders an index. Each `.md`'s frontmatter carries `name`
|
|
3498
|
+
* and `description`; bodies are never read — the agent loads a specific
|
|
3499
|
+
* memory's body on demand via the `read` tool.
|
|
2225
3500
|
*
|
|
2226
|
-
*
|
|
2227
|
-
*
|
|
2228
|
-
*
|
|
2229
|
-
*
|
|
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.
|
|
3501
|
+
* Mtime cache (for the derived per-user half) keyed by cwd — within the
|
|
3502
|
+
* lifetime of a sandbox the cwd is fixed, so this is effectively a single-entry
|
|
3503
|
+
* cache. It invalidates when any `.md` under `users/` is added/modified/
|
|
3504
|
+
* deleted; turns where memory didn't change reuse the cached entries.
|
|
2233
3505
|
*
|
|
2234
|
-
*
|
|
2235
|
-
*
|
|
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.
|
|
3506
|
+
* Frontmatter is parsed as YAML (`yaml` package) and validated with a zod
|
|
3507
|
+
* schema — files that don't match the shape are dropped from the index.
|
|
2243
3508
|
*/
|
|
2244
3509
|
const FRONTMATTER_READ_BYTES = 4096;
|
|
2245
3510
|
const FrontmatterSchema = z.object({
|
|
@@ -2247,33 +3512,118 @@ const FrontmatterSchema = z.object({
|
|
|
2247
3512
|
description: z.string().min(1)
|
|
2248
3513
|
}).passthrough();
|
|
2249
3514
|
const MEMORY_DIRNAME = ".memory";
|
|
2250
|
-
const
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
3515
|
+
const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
3516
|
+
const USERS_DIRNAME = "users";
|
|
3517
|
+
/**
|
|
3518
|
+
* The shared-knowledge type dirs from the old frontmatter-indexed layout, used
|
|
3519
|
+
* only to seed a `MEMORY.md` for agents created before it existed (see
|
|
3520
|
+
* `seedMemoryIndexFile`). `users/` is deliberately excluded — per-person memory
|
|
3521
|
+
* stays derived and never lands in the shared, un-scoped `MEMORY.md`.
|
|
3522
|
+
*/
|
|
3523
|
+
const LEGACY_SHARED_TYPES = [
|
|
3524
|
+
{
|
|
3525
|
+
dir: "projects",
|
|
3526
|
+
label: "Projects"
|
|
3527
|
+
},
|
|
3528
|
+
{
|
|
3529
|
+
dir: "feedback",
|
|
3530
|
+
label: "Feedback"
|
|
3531
|
+
},
|
|
3532
|
+
{
|
|
3533
|
+
dir: "reference",
|
|
3534
|
+
label: "Reference"
|
|
3535
|
+
}
|
|
2255
3536
|
];
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
3537
|
+
/**
|
|
3538
|
+
* Soft budget for an injected index block. The index is read and injected into
|
|
3539
|
+
* the system prompt on every turn, so every entry costs context for the rest of
|
|
3540
|
+
* the conversation. Past this size we nudge the agent to consolidate and prune
|
|
3541
|
+
* rather than keep appending. Not a hard cap — nothing is truncated.
|
|
3542
|
+
*/
|
|
3543
|
+
const MEMORY_INDEX_SOFT_BUDGET_CHARS = 2e4;
|
|
3544
|
+
/**
|
|
3545
|
+
* Hard cap on the injected index — double the soft budget. The soft budget only
|
|
3546
|
+
* warns; this actually bounds what we inject so a runaway index can't consume
|
|
3547
|
+
* unbounded context on every turn. Past this, the index is truncated (on a line
|
|
3548
|
+
* boundary) before injection. It's a backstop, not a normal operating point.
|
|
3549
|
+
*/
|
|
3550
|
+
const MEMORY_INDEX_HARD_BUDGET_CHARS = MEMORY_INDEX_SOFT_BUDGET_CHARS * 2;
|
|
3551
|
+
/**
|
|
3552
|
+
* Cheap size summary of a rendered index block, used to surface how much of the
|
|
3553
|
+
* every-turn context budget the index is spending so the agent keeps it lean.
|
|
3554
|
+
* Counts pointer/file lines — both the derived `` - `path` — desc`` form and
|
|
3555
|
+
* the hand-maintained `- [Title](path) — hook` form — not the group headers.
|
|
3556
|
+
*/
|
|
3557
|
+
function summarizeIndex(index) {
|
|
3558
|
+
const entryCount = index.split("\n").filter((line) => /^\s*- (?:`|\[)/.test(line)).length;
|
|
3559
|
+
const charCount = index.length;
|
|
3560
|
+
return {
|
|
3561
|
+
entryCount,
|
|
3562
|
+
charCount,
|
|
3563
|
+
overBudget: charCount > MEMORY_INDEX_SOFT_BUDGET_CHARS
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
/**
|
|
3567
|
+
* One-line size note for an index block header, e.g. `12 entries, 3187 chars`.
|
|
3568
|
+
*/
|
|
3569
|
+
function indexSizeNote(index) {
|
|
3570
|
+
const { entryCount, charCount } = summarizeIndex(index);
|
|
3571
|
+
return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}, ${charCount} chars`;
|
|
3572
|
+
}
|
|
3573
|
+
/**
|
|
3574
|
+
* An explicit warning to surface to the agent when an index has grown past its
|
|
3575
|
+
* budget, or `null` when it's within budget. Extensions render this prominently
|
|
3576
|
+
* above the index so the agent prunes before it keeps appending.
|
|
3577
|
+
*/
|
|
3578
|
+
function indexBudgetWarning(index) {
|
|
3579
|
+
const { charCount, overBudget } = summarizeIndex(index);
|
|
3580
|
+
if (!overBudget) return null;
|
|
3581
|
+
return `⚠️ This memory index is ${charCount} chars, over its ${MEMORY_INDEX_SOFT_BUDGET_CHARS}-char budget. It's costing you context on every turn — consolidate duplicate entries and delete stale ones to bring it back under budget before adding anything new.`;
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* Enforce the hard cap on an index before injection. Under the cap the index is
|
|
3585
|
+
* returned unchanged; over it, the index is truncated on a line boundary and a
|
|
3586
|
+
* notice is appended naming the true size so the agent knows entries are hidden
|
|
3587
|
+
* and must be pruned. This is the actual bound on injected context — callers
|
|
3588
|
+
* still report the true size via {@link indexSizeNote} so nothing is masked.
|
|
3589
|
+
*/
|
|
3590
|
+
function enforceMemoryIndexHardBudget(index) {
|
|
3591
|
+
if (index.length <= 4e4) return index;
|
|
3592
|
+
const clipped = index.slice(0, MEMORY_INDEX_HARD_BUDGET_CHARS);
|
|
3593
|
+
const lastNewline = clipped.lastIndexOf("\n");
|
|
3594
|
+
return `${lastNewline > 0 ? clipped.slice(0, lastNewline) : clipped}\n\n⚠️ Memory index truncated at ${MEMORY_INDEX_HARD_BUDGET_CHARS} chars (it is ${index.length}). Entries past this point are NOT shown. Prune the index now — delete stale entries and consolidate duplicates.`;
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* Read the agent's hand-maintained shared index at `.memory/MEMORY.md`.
|
|
3598
|
+
* Returns the trimmed contents, or `null` when the file is absent or empty —
|
|
3599
|
+
* the agent owns this file, so we surface exactly what it wrote.
|
|
3600
|
+
*/
|
|
3601
|
+
async function readMemoryIndexFile({ cwd }) {
|
|
3602
|
+
const path = join(cwd, MEMORY_DIRNAME, MEMORY_INDEX_FILENAME);
|
|
3603
|
+
try {
|
|
3604
|
+
const trimmed = (await readFile(path, "utf-8")).trim();
|
|
3605
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3606
|
+
} catch {
|
|
3607
|
+
return null;
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
2263
3610
|
const cache = /* @__PURE__ */ new Map();
|
|
2264
3611
|
/**
|
|
3612
|
+
* Build the derived per-person index for a single user.
|
|
3613
|
+
*
|
|
2265
3614
|
* Returns:
|
|
2266
|
-
* - `null` if `.memory/` doesn't exist
|
|
2267
|
-
* - `""` if
|
|
3615
|
+
* - `null` if `.memory/users/` doesn't exist
|
|
3616
|
+
* - `""` if it exists but this user has no memory
|
|
2268
3617
|
* - rendered markdown body (no surrounding header — caller wraps)
|
|
2269
3618
|
*
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
3619
|
+
* Scoping by id keeps one person's memory from bleeding into another's
|
|
3620
|
+
* conversation. The mtime-keyed cache stores the raw walked entries (the cost
|
|
3621
|
+
* is the FS walk); filtering by user is cheap and runs per call, so two turns
|
|
3622
|
+
* with different users on the same cwd render correctly from one cached walk.
|
|
2273
3623
|
*/
|
|
2274
|
-
async function buildMemoryIndex({ cwd,
|
|
2275
|
-
const
|
|
2276
|
-
const maxMtimeMs = await maxMtimeAcrossDir(
|
|
3624
|
+
async function buildMemoryIndex({ cwd, userId }) {
|
|
3625
|
+
const usersDirAbs = join(cwd, MEMORY_DIRNAME, USERS_DIRNAME);
|
|
3626
|
+
const maxMtimeMs = await maxMtimeAcrossDir(usersDirAbs);
|
|
2277
3627
|
if (maxMtimeMs === null) {
|
|
2278
3628
|
cache.delete(cwd);
|
|
2279
3629
|
return null;
|
|
@@ -2281,13 +3631,13 @@ async function buildMemoryIndex({ cwd, scope }) {
|
|
|
2281
3631
|
let cached = cache.get(cwd);
|
|
2282
3632
|
if (!cached || cached.builtAtMs < maxMtimeMs) {
|
|
2283
3633
|
cached = {
|
|
2284
|
-
entries: await
|
|
3634
|
+
entries: await collectUserEntries(usersDirAbs, cwd),
|
|
2285
3635
|
builtAtMs: Date.now()
|
|
2286
3636
|
};
|
|
2287
3637
|
cache.set(cwd, cached);
|
|
2288
3638
|
}
|
|
2289
|
-
const visible = cached.entries.filter((entry) =>
|
|
2290
|
-
return visible.length === 0 ? "" :
|
|
3639
|
+
const visible = cached.entries.filter((entry) => entry.subject.startsWith(userId));
|
|
3640
|
+
return visible.length === 0 ? "" : renderUserIndex(visible);
|
|
2291
3641
|
}
|
|
2292
3642
|
async function maxMtimeAcrossDir(dir) {
|
|
2293
3643
|
let dirStat;
|
|
@@ -2335,45 +3685,22 @@ async function listSubdirs(dir) {
|
|
|
2335
3685
|
}
|
|
2336
3686
|
return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
|
|
2337
3687
|
}
|
|
2338
|
-
async function
|
|
2339
|
-
const
|
|
2340
|
-
await Promise.all(
|
|
2341
|
-
const
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
await
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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;
|
|
3688
|
+
async function collectUserEntries(usersDirAbs, cwd) {
|
|
3689
|
+
const subjectDirs = await listSubdirs(usersDirAbs);
|
|
3690
|
+
return (await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
|
|
3691
|
+
const subject = basename(subjectDirAbs);
|
|
3692
|
+
const files = await listMdFilesShallow(subjectDirAbs);
|
|
3693
|
+
return (await Promise.all(files.map(async (file) => {
|
|
3694
|
+
const fm = await readFrontmatterOnly(file);
|
|
3695
|
+
if (!fm?.name || !fm?.description) return null;
|
|
3696
|
+
return {
|
|
3697
|
+
name: fm.name,
|
|
3698
|
+
description: fm.description,
|
|
3699
|
+
subject,
|
|
3700
|
+
relPath: relative(cwd, file)
|
|
3701
|
+
};
|
|
3702
|
+
}))).filter((e) => e !== null);
|
|
3703
|
+
}))).flat();
|
|
2377
3704
|
}
|
|
2378
3705
|
async function readFrontmatterOnly(filePath) {
|
|
2379
3706
|
let fh;
|
|
@@ -2404,77 +3731,189 @@ function parseFrontmatter(text) {
|
|
|
2404
3731
|
const result = FrontmatterSchema.safeParse(parsed);
|
|
2405
3732
|
return result.success ? result.data : null;
|
|
2406
3733
|
}
|
|
2407
|
-
function
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
}
|
|
2414
|
-
|
|
3734
|
+
function renderUserIndex(entries) {
|
|
3735
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
3736
|
+
for (const e of entries) {
|
|
3737
|
+
const list = bySubject.get(e.subject) ?? [];
|
|
3738
|
+
list.push(e);
|
|
3739
|
+
bySubject.set(e.subject, list);
|
|
3740
|
+
}
|
|
3741
|
+
const lines = ["### Users"];
|
|
3742
|
+
for (const subject of [...bySubject.keys()].sort()) {
|
|
3743
|
+
lines.push(`- **${subject}**`);
|
|
3744
|
+
for (const e of bySubject.get(subject) ?? []) lines.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
3745
|
+
}
|
|
3746
|
+
return lines.join("\n");
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* One-time migration for agents created before `MEMORY.md` existed. If there is
|
|
3750
|
+
* no hand-maintained `.memory/MEMORY.md` yet but the agent has shared memory
|
|
3751
|
+
* files from the old frontmatter-indexed layout (`projects/`, `feedback/`,
|
|
3752
|
+
* `reference/`), derive a `MEMORY.md` from their frontmatter and write it once.
|
|
3753
|
+
* After that the agent owns the file — this never runs again for that agent and
|
|
3754
|
+
* never clobbers an existing index.
|
|
3755
|
+
*
|
|
3756
|
+
* Returns the seeded contents (also written to disk), or `null` when nothing
|
|
3757
|
+
* was seeded (index already present, or no legacy shared files). A write
|
|
3758
|
+
* failure propagates so the caller can log it; the read path then falls back to
|
|
3759
|
+
* whatever is on disk.
|
|
3760
|
+
*/
|
|
3761
|
+
async function seedMemoryIndexFile({ cwd }) {
|
|
3762
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3763
|
+
const indexPath = join(memoryDirAbs, MEMORY_INDEX_FILENAME);
|
|
3764
|
+
if (await stat(indexPath).catch(() => null)) return null;
|
|
3765
|
+
const perType = await Promise.all(LEGACY_SHARED_TYPES.map(async ({ dir, label }) => {
|
|
3766
|
+
const typeDirAbs = join(memoryDirAbs, dir);
|
|
3767
|
+
const files = [];
|
|
3768
|
+
await walkMdFiles(typeDirAbs, files);
|
|
3769
|
+
return {
|
|
3770
|
+
label,
|
|
3771
|
+
entries: (await Promise.all(files.map(async (file) => {
|
|
3772
|
+
const fm = await readFrontmatterOnly(file);
|
|
3773
|
+
if (!fm?.name || !fm?.description) return null;
|
|
3774
|
+
return {
|
|
3775
|
+
name: fm.name,
|
|
3776
|
+
description: fm.description,
|
|
3777
|
+
relPath: relative(cwd, file)
|
|
3778
|
+
};
|
|
3779
|
+
}))).filter((e) => !!e)
|
|
3780
|
+
};
|
|
3781
|
+
}));
|
|
3782
|
+
if (perType.every((group) => group.entries.length === 0)) return null;
|
|
3783
|
+
const content = renderSeededIndex(perType);
|
|
3784
|
+
await writeFile(indexPath, `${content}\n`, "utf-8");
|
|
3785
|
+
return content;
|
|
3786
|
+
}
|
|
3787
|
+
function renderSeededIndex(groups) {
|
|
2415
3788
|
const sections = [];
|
|
2416
|
-
for (const
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
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}`);
|
|
3789
|
+
for (const { label, entries } of groups) {
|
|
3790
|
+
if (entries.length === 0) continue;
|
|
3791
|
+
sections.push(`### ${label}`);
|
|
3792
|
+
const sorted = [...entries].sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
3793
|
+
for (const e of sorted) sections.push(`- [${e.name}](${e.relPath}) — ${e.description}`);
|
|
2434
3794
|
sections.push("");
|
|
2435
3795
|
}
|
|
2436
3796
|
return sections.join("\n").trimEnd();
|
|
2437
3797
|
}
|
|
3798
|
+
/**
|
|
3799
|
+
* The version at which the hand-maintained `MEMORY.md` layout was introduced.
|
|
3800
|
+
* Used to classify an unversioned `.memory/`: if it already has a `MEMORY.md`
|
|
3801
|
+
* it's on this layout (not the pre-MEMORY.md v1 frontmatter layout), so it
|
|
3802
|
+
* shouldn't be treated as v1 and re-seeded.
|
|
3803
|
+
*/
|
|
3804
|
+
const HAND_MAINTAINED_INDEX_VERSION = 2;
|
|
3805
|
+
const VERSION_FILENAME = ".version";
|
|
3806
|
+
const MEMORY_MIGRATIONS = [{
|
|
3807
|
+
from: 1,
|
|
3808
|
+
to: 2,
|
|
3809
|
+
apply: async ({ cwd }) => {
|
|
3810
|
+
await seedMemoryIndexFile({ cwd });
|
|
3811
|
+
}
|
|
3812
|
+
}];
|
|
3813
|
+
/**
|
|
3814
|
+
* The layout version of an agent's `.memory/`:
|
|
3815
|
+
* - `null` when there's no `.memory/` at all (a fresh agent is current by
|
|
3816
|
+
* construction; nothing to migrate).
|
|
3817
|
+
* - `1` when `.memory/` exists but carries no `.version` marker — i.e. it
|
|
3818
|
+
* predates versioning.
|
|
3819
|
+
* - otherwise the integer in `.memory/.version`.
|
|
3820
|
+
*/
|
|
3821
|
+
async function readMemoryVersion(cwd) {
|
|
3822
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3823
|
+
if (!(await stat(memoryDirAbs).catch(() => null))?.isDirectory()) return null;
|
|
3824
|
+
const raw = await readFile(join(memoryDirAbs, VERSION_FILENAME), "utf-8").catch(() => null);
|
|
3825
|
+
if (raw === null) return await stat(join(memoryDirAbs, MEMORY_INDEX_FILENAME)).then((s) => s.isFile()).catch(() => false) ? HAND_MAINTAINED_INDEX_VERSION : 1;
|
|
3826
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
3827
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
|
3828
|
+
}
|
|
3829
|
+
async function writeMemoryVersion(cwd, version) {
|
|
3830
|
+
await writeFile(join(cwd, MEMORY_DIRNAME, VERSION_FILENAME), `${version}\n`, "utf-8");
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* Bring an agent's `.memory/` up to `CURRENT_MEMORY_VERSION` by applying the
|
|
3834
|
+
* ordered migrations. Runs on session start. No-op when there's no `.memory/`
|
|
3835
|
+
* yet or it's already current. Migrations must be idempotent, so a lost/unwritten
|
|
3836
|
+
* version marker (the file isn't committed by the harness) only costs a repeated
|
|
3837
|
+
* no-op, never corruption. Returns the `{ from, to }` actually applied, or
|
|
3838
|
+
* `null` when nothing ran.
|
|
3839
|
+
*/
|
|
3840
|
+
async function migrateMemory({ cwd }) {
|
|
3841
|
+
const from = await readMemoryVersion(cwd);
|
|
3842
|
+
if (from === null || from >= 2) return null;
|
|
3843
|
+
let version = from;
|
|
3844
|
+
while (version < 2) {
|
|
3845
|
+
const migration = MEMORY_MIGRATIONS.find((m) => m.from === version);
|
|
3846
|
+
if (!migration) break;
|
|
3847
|
+
await migration.apply({ cwd });
|
|
3848
|
+
version = migration.to;
|
|
3849
|
+
}
|
|
3850
|
+
await writeMemoryVersion(cwd, version);
|
|
3851
|
+
return {
|
|
3852
|
+
from,
|
|
3853
|
+
to: version
|
|
3854
|
+
};
|
|
3855
|
+
}
|
|
2438
3856
|
//#endregion
|
|
2439
3857
|
//#region src/extensions/memory.ts
|
|
2440
|
-
const log$
|
|
3858
|
+
const log$5 = logger.child({ module: "memory-extension" });
|
|
2441
3859
|
/**
|
|
2442
3860
|
* The standing instructions for the memory system. Always injected (even with
|
|
2443
|
-
*
|
|
3861
|
+
* no `MEMORY.md`) so the agent knows it can persist notes and how. `users/` is
|
|
2444
3862
|
* described by the platform memory extension, which is the only thing that can
|
|
2445
3863
|
* scope it to a person — here we just point at it.
|
|
2446
3864
|
*/
|
|
2447
3865
|
function memoryInstructions(cwd) {
|
|
2448
3866
|
return `## Memory across conversations
|
|
2449
3867
|
|
|
2450
|
-
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo
|
|
3868
|
+
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo, one fact per file. You maintain a hand-written index of them at \`${cwd}/.memory/MEMORY.md\`, and the harness injects that index into your system prompt every turn. **Bodies are NOT auto-loaded** — when an index line looks relevant, use your \`read\` tool to load that specific file.
|
|
2451
3869
|
|
|
2452
3870
|
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
3871
|
|
|
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
|
|
3872
|
+
You own \`MEMORY.md\`. When you learn something durable, write the fact to its own \`.md\` file and add a one-line pointer to \`MEMORY.md\` in the form \`- [Title](relative/path.md) — one-line hook\`, where the hook is what tells future-you when to open the file. \`MEMORY.md\` is an *index*, never a store — put the actual content in the topic file and only a pointer line in \`MEMORY.md\`; do not inline a fact's body into the index even when it seems cheaper. Start each topic file with \`name:\`/\`description:\` frontmatter (the \`description\` is the one-line hook) so the index can be re-seeded, re-derived, or linted from the files themselves. When a fact changes, edit both the file and its line; when it stops being true, delete the file and its line. Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are indexed for you separately, scoped to whoever you're talking to — don't put people's private notes in the shared \`MEMORY.md\`.)
|
|
3873
|
+
|
|
3874
|
+
Keep \`MEMORY.md\` lean. It's re-injected on *every* turn, so a small, high-signal index is worth far more than an exhaustive one — curate it like a tightly-edited table of contents, not a log:
|
|
3875
|
+
- Be selective. Only record something durable that will matter in a *future* conversation. Don't record what only matters right now, what you can re-derive on demand, or what's already obvious from the repo.
|
|
3876
|
+
- Consolidate before you create. Before adding a line, scan \`MEMORY.md\` for one that already covers the topic; if it exists, \`read\` that file and rewrite it with the new facts merged in rather than adding a near-duplicate. One fact per file, but don't fragment a topic across many thin files.
|
|
3877
|
+
- Prune as you go. Delete lines (and their files) that are wrong, stale, or superseded. The index header reports its size — when it's flagged over budget, consolidate and delete before adding anything new.
|
|
3878
|
+
- Write dates absolute, not relative. Resolve "last week" / "yesterday" to a concrete date when you record it (e.g. "on 7/3 Dhruv told me to …"), so the note still reads correctly in a future conversation.
|
|
3879
|
+
|
|
3880
|
+
Commit and push after editing \`.memory/\` to persist it.`;
|
|
2455
3881
|
}
|
|
2456
3882
|
function composeBlock$1({ cwd, index }) {
|
|
2457
3883
|
const instructions = memoryInstructions(cwd);
|
|
2458
3884
|
if (!index || index.length === 0) return instructions;
|
|
2459
|
-
|
|
3885
|
+
const header = `## Memory index (${indexSizeNote(index)})`;
|
|
3886
|
+
const warning = indexBudgetWarning(index);
|
|
3887
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3888
|
+
return `${instructions}\n\n${warning ? `${header}\n\n${warning}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2460
3889
|
}
|
|
2461
3890
|
const memoryExtension = (pi) => {
|
|
2462
3891
|
let cachedBlock = null;
|
|
2463
3892
|
pi.on("session_start", async (_event, ctx) => {
|
|
2464
3893
|
try {
|
|
2465
|
-
const
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
3894
|
+
const migrated = await migrateMemory({ cwd: ctx.cwd });
|
|
3895
|
+
if (migrated !== null) log$5.info({
|
|
3896
|
+
event: "memory_migrated",
|
|
3897
|
+
from: migrated.from,
|
|
3898
|
+
to: migrated.to
|
|
3899
|
+
}, "migrated memory layout to current version");
|
|
3900
|
+
} catch (err) {
|
|
3901
|
+
log$5.warn({
|
|
3902
|
+
err,
|
|
3903
|
+
event: "memory_migration_failed"
|
|
3904
|
+
}, "memory migration failed; continuing with existing index");
|
|
3905
|
+
}
|
|
3906
|
+
try {
|
|
3907
|
+
const index = await readMemoryIndexFile({ cwd: ctx.cwd });
|
|
2469
3908
|
cachedBlock = composeBlock$1({
|
|
2470
3909
|
cwd: ctx.cwd,
|
|
2471
3910
|
index
|
|
2472
3911
|
});
|
|
2473
3912
|
} catch (err) {
|
|
2474
|
-
log$
|
|
3913
|
+
log$5.warn({
|
|
2475
3914
|
err,
|
|
2476
3915
|
event: "memory_index_failed"
|
|
2477
|
-
}, "memory index
|
|
3916
|
+
}, "memory index read failed; injecting instructions only");
|
|
2478
3917
|
cachedBlock = memoryInstructions(ctx.cwd);
|
|
2479
3918
|
}
|
|
2480
3919
|
});
|
|
@@ -2485,7 +3924,7 @@ const memoryExtension = (pi) => {
|
|
|
2485
3924
|
};
|
|
2486
3925
|
//#endregion
|
|
2487
3926
|
//#region src/extensions/platform-memory.ts
|
|
2488
|
-
const log$
|
|
3927
|
+
const log$4 = logger.child({ module: "platform-memory-extension" });
|
|
2489
3928
|
/**
|
|
2490
3929
|
* Resolve the human on this turn via the API, keyed by the message id.
|
|
2491
3930
|
* `/sandbox/channel-context` only returns a sender for a platform-known
|
|
@@ -2498,13 +3937,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
|
|
|
2498
3937
|
async function resolveTurnUser(messageId) {
|
|
2499
3938
|
const client = sandboxClient();
|
|
2500
3939
|
if (!client) {
|
|
2501
|
-
log$
|
|
3940
|
+
log$4.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
|
|
2502
3941
|
return null;
|
|
2503
3942
|
}
|
|
2504
3943
|
try {
|
|
2505
3944
|
const res = await client["channel-context"].$get({ query: { messageId } });
|
|
2506
3945
|
if (!res.ok) {
|
|
2507
|
-
log$
|
|
3946
|
+
log$4.warn({
|
|
2508
3947
|
event: "resolve_turn_user_failed",
|
|
2509
3948
|
status: res.status
|
|
2510
3949
|
}, "channel-context returned non-ok; withholding user memory");
|
|
@@ -2517,7 +3956,7 @@ async function resolveTurnUser(messageId) {
|
|
|
2517
3956
|
displayName: sender.displayName
|
|
2518
3957
|
};
|
|
2519
3958
|
} catch (err) {
|
|
2520
|
-
log$
|
|
3959
|
+
log$4.warn({
|
|
2521
3960
|
err,
|
|
2522
3961
|
event: "resolve_turn_user_failed"
|
|
2523
3962
|
}, "failed to resolve current user; withholding user memory");
|
|
@@ -2533,9 +3972,12 @@ function slugifyName(name) {
|
|
|
2533
3972
|
function composeBlock({ index, user }) {
|
|
2534
3973
|
const instructions = `## Current user memory
|
|
2535
3974
|
|
|
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.`;
|
|
3975
|
+
Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory. Keep it lean and be selective — this is re-injected every turn; consolidate related facts into one file and delete what's stale rather than piling on near-duplicates.`;
|
|
2537
3976
|
if (!index || index.length === 0) return instructions;
|
|
2538
|
-
|
|
3977
|
+
const warning = indexBudgetWarning(index);
|
|
3978
|
+
const header = `Memory index (${indexSizeNote(index)}):`;
|
|
3979
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3980
|
+
return `${instructions}\n\n${warning ? `${warning}\n\n${header}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2539
3981
|
}
|
|
2540
3982
|
/**
|
|
2541
3983
|
* Build the platform memory extension. `channelContext` is the per-turn ref
|
|
@@ -2556,15 +3998,12 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2556
3998
|
cachedBlock = composeBlock({
|
|
2557
3999
|
index: await buildMemoryIndex({
|
|
2558
4000
|
cwd: ctx.cwd,
|
|
2559
|
-
|
|
2560
|
-
kind: "user",
|
|
2561
|
-
userId: user.id
|
|
2562
|
-
}
|
|
4001
|
+
userId: user.id
|
|
2563
4002
|
}),
|
|
2564
4003
|
user
|
|
2565
4004
|
});
|
|
2566
4005
|
} catch (err) {
|
|
2567
|
-
log$
|
|
4006
|
+
log$4.warn({
|
|
2568
4007
|
err,
|
|
2569
4008
|
event: "user_memory_index_failed"
|
|
2570
4009
|
}, "user memory index build failed; skipping injection");
|
|
@@ -2578,8 +4017,22 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2578
4017
|
};
|
|
2579
4018
|
}
|
|
2580
4019
|
//#endregion
|
|
4020
|
+
//#region src/extensions/provider-api-compat.ts
|
|
4021
|
+
const OPENAI_COMPATIBLE_PROVIDERS = ["google", "openai"];
|
|
4022
|
+
const OPENAI_COMPLETIONS_API = "openai-completions";
|
|
4023
|
+
/**
|
|
4024
|
+
* Keep older harnesses compatible with provider-native model specs that use
|
|
4025
|
+
* the OpenAI-compatible wire through the transparent proxy.
|
|
4026
|
+
*/
|
|
4027
|
+
const providerApiCompatExtension = (pi) => {
|
|
4028
|
+
for (const provider of OPENAI_COMPATIBLE_PROVIDERS) pi.registerProvider(provider, { models: getModels(provider).map((model) => ({
|
|
4029
|
+
...model,
|
|
4030
|
+
api: OPENAI_COMPLETIONS_API
|
|
4031
|
+
})) });
|
|
4032
|
+
};
|
|
4033
|
+
//#endregion
|
|
2581
4034
|
//#region src/extensions/self-trace.ts
|
|
2582
|
-
const log$
|
|
4035
|
+
const log$3 = logger.child({ module: "self-trace-extension" });
|
|
2583
4036
|
/**
|
|
2584
4037
|
* Reports the agent's own execution as OpenTelemetry spans:
|
|
2585
4038
|
* agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
|
|
@@ -2604,7 +4057,7 @@ const selfTraceExtension = (pi) => {
|
|
|
2604
4057
|
sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
|
|
2605
4058
|
sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
|
|
2606
4059
|
const sc = sessionSpan.spanContext();
|
|
2607
|
-
log$
|
|
4060
|
+
log$3.info({
|
|
2608
4061
|
event: "self_trace_session_start",
|
|
2609
4062
|
trace_id: sc.traceId,
|
|
2610
4063
|
span_id: sc.spanId,
|
|
@@ -2716,13 +4169,13 @@ const selfTraceExtension = (pi) => {
|
|
|
2716
4169
|
* Lives in the harness package — soul.md is content from the agent's
|
|
2717
4170
|
* own git repo, not from the platform — so its handling stays here.
|
|
2718
4171
|
*/
|
|
2719
|
-
const log$
|
|
4172
|
+
const log$2 = logger.child({ module: "soul-extension" });
|
|
2720
4173
|
async function readSoul(cwd) {
|
|
2721
4174
|
try {
|
|
2722
4175
|
return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
|
|
2723
4176
|
} catch (err) {
|
|
2724
4177
|
if (err?.code === "ENOENT") return null;
|
|
2725
|
-
log$
|
|
4178
|
+
log$2.warn({
|
|
2726
4179
|
err,
|
|
2727
4180
|
event: "soul_read_failed"
|
|
2728
4181
|
}, "soul.md read failed");
|
|
@@ -2736,7 +4189,7 @@ function soulSection(cwd, soul) {
|
|
|
2736
4189
|
|
|
2737
4190
|
**\`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
4191
|
|
|
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.
|
|
4192
|
+
Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect, a secret or auth credential you wire in — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Do this the moment you gain the capability, and for a credential that means the moment it verifies with a real call, not after a human points out that you forgot. Connecting a capability is itself a durable change worth recording, not merely a step toward the task in front of you. Edit \`soul.md\` (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
|
|
2740
4193
|
|
|
2741
4194
|
${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
|
|
2742
4195
|
}
|
|
@@ -2754,13 +4207,23 @@ const soulExtension = (pi) => {
|
|
|
2754
4207
|
};
|
|
2755
4208
|
//#endregion
|
|
2756
4209
|
//#region src/extensions/subagent/index.ts
|
|
2757
|
-
const log$
|
|
4210
|
+
const log$1 = logger.child({ module: "subagent-ext" });
|
|
2758
4211
|
const MAX_TASKS = 8;
|
|
2759
4212
|
const TaskItem = Type.Object({
|
|
2760
4213
|
task: Type.String({ description: "The task to delegate to a subagent run." }),
|
|
4214
|
+
title: Type.String({
|
|
4215
|
+
description: "A SHORT name for this task — 3-6 words, sentence case, no trailing period. This is what the person in the chat sees as the row for this subagent, so name the work, do not restate the prompt. Good: \"Audit the billing gate\", \"Compare competitor pricing\", \"Draft the migration\". Bad: \"You are looking at apps/anyone/web and should check every component…\".",
|
|
4216
|
+
maxLength: 120
|
|
4217
|
+
}),
|
|
2761
4218
|
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
|
|
4219
|
+
model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on. PREFER A LOWER-COST, FASTER MODEL when the task is well-scoped and does not need your full reasoning depth — most delegated subtasks (searching, summarizing, mechanical edits, gathering or reformatting data, running a check) run just as well on a lighter model and cost far less. Reserve a top-tier model for subtasks that genuinely need deep reasoning or careful judgment. Must be a real catalogued model id. Omit to inherit your own model. If you are locked to a Google-compliant model, only compliant models are accepted." })),
|
|
4220
|
+
timeoutMinutes: Type.Optional(Type.Integer({
|
|
4221
|
+
description: "Optional wall-clock timeout for this subagent, in minutes. If the run is still going after this long it is ended and you are rewoken with a timeout result, so a hung subagent can never strand you. Omit for the default (30 minutes). Raise it for genuinely long work (a big migration, a large audit); lower it for a quick lookup. Range 1-360.",
|
|
4222
|
+
minimum: 1,
|
|
4223
|
+
maximum: 360
|
|
4224
|
+
}))
|
|
2763
4225
|
});
|
|
4226
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
|
|
2764
4227
|
const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
|
|
2765
4228
|
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
4229
|
minItems: 1,
|
|
@@ -2775,7 +4238,9 @@ function buildTool(messageId) {
|
|
|
2775
4238
|
"Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
|
|
2776
4239
|
"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
4240
|
"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
|
|
4241
|
+
"Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task — prefer a lower-cost, faster model for well-scoped subtasks that don't need deep reasoning, and reserve a top-tier model for the ones that do; omit it to inherit your own model.",
|
|
4242
|
+
"Peering: each queued task comes back with its own conversation id. A subagent is a real linked conversation, so to see what one is doing RIGHT NOW while it runs — its reasoning, the tools it has called and their results, its progress — read that conversation with `platform conversations show <conversationId>` (you are already authorized; it is your own delegated run). Check in that way instead of waiting blind for the final result. The read reflects the child's persisted state, which lags a few seconds behind live (tool results land as they complete; in-progress reasoning can be up to ~5s stale), so peek between checkpoints rather than polling in a tight loop.",
|
|
4243
|
+
"Steering: to add context, correct course, or answer a question a subagent needs mid-run, post to its conversation with `platform conversations post <conversationId> --message \"...\"`. If the subagent is still running, your message lands as a live steer picked up in that same turn; if it has gone idle, it queues as its next turn. This is the same primitive as any conversation message — there is no separate steer channel."
|
|
2779
4244
|
].join(" "),
|
|
2780
4245
|
promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
|
|
2781
4246
|
parameters: SubagentParams,
|
|
@@ -2791,29 +4256,41 @@ function buildTool(messageId) {
|
|
|
2791
4256
|
};
|
|
2792
4257
|
const spawnTasks = tasks.map((t) => ({
|
|
2793
4258
|
task: t.task,
|
|
4259
|
+
title: t.title ?? null,
|
|
2794
4260
|
persona: t.persona ?? null,
|
|
2795
|
-
model: t.model ?? null
|
|
4261
|
+
model: t.model ?? null,
|
|
4262
|
+
timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
|
|
2796
4263
|
}));
|
|
2797
4264
|
try {
|
|
2798
|
-
const
|
|
4265
|
+
const spawned = await postSubagentSpawn({
|
|
2799
4266
|
messageId,
|
|
2800
4267
|
tasks: spawnTasks
|
|
2801
4268
|
});
|
|
2802
|
-
|
|
4269
|
+
const { taskIds } = spawned;
|
|
4270
|
+
log$1.info({
|
|
2803
4271
|
event: "subagent_spawned",
|
|
2804
4272
|
count: taskIds.length
|
|
2805
4273
|
}, "subagent tasks queued");
|
|
2806
|
-
const
|
|
4274
|
+
const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
|
|
4275
|
+
const lines = taskIds.map((id, i) => {
|
|
4276
|
+
const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
|
|
4277
|
+
const conv = convByTask.get(id);
|
|
4278
|
+
return conv ? `- conversation ${conv}: ${label} (taskId ${id})` : `- ${label} (taskId ${id}, conversation id unavailable — cannot peer this run)`;
|
|
4279
|
+
}).join("\n");
|
|
4280
|
+
const peerHint = spawned.tasks.length ? "\nEach subagent runs on its own conversation (id shown per task above). To SEE what one is doing while it runs, read it with `platform conversations show <conversationId>`. To STEER one mid-run — add context, correct course, answer a question — post to its conversation with `platform conversations post <conversationId> --message \"...\"`; it lands as a live steer if the subagent is still running, or as its next turn if it has gone idle." : "";
|
|
2807
4281
|
return {
|
|
2808
4282
|
content: [{
|
|
2809
4283
|
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}`
|
|
4284
|
+
text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}${peerHint}`
|
|
2811
4285
|
}],
|
|
2812
|
-
details: {
|
|
4286
|
+
details: {
|
|
4287
|
+
taskIds,
|
|
4288
|
+
tasks: spawned.tasks
|
|
4289
|
+
}
|
|
2813
4290
|
};
|
|
2814
4291
|
} catch (err) {
|
|
2815
4292
|
const message = err instanceof Error ? err.message : String(err);
|
|
2816
|
-
log$
|
|
4293
|
+
log$1.warn({
|
|
2817
4294
|
err,
|
|
2818
4295
|
event: "subagent_spawn_failed"
|
|
2819
4296
|
}, "subagent spawn failed");
|
|
@@ -2830,20 +4307,145 @@ function buildTool(messageId) {
|
|
|
2830
4307
|
};
|
|
2831
4308
|
}
|
|
2832
4309
|
/**
|
|
2833
|
-
* Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
|
|
2834
4310
|
* The factory takes the session's channel context to resolve the originating
|
|
2835
4311
|
* messageId — the api links each spawned run to the conversation that message
|
|
2836
4312
|
* belongs to and rewakes it on completion (nothing about the parent is piped
|
|
2837
|
-
* from the sandbox beyond that id).
|
|
4313
|
+
* from the sandbox beyond that id). The tool is registered unconditionally at
|
|
4314
|
+
* session_start.
|
|
2838
4315
|
*/
|
|
2839
4316
|
function createSubagentExtension({ channelContext }) {
|
|
2840
4317
|
return (pi) => {
|
|
2841
4318
|
const messageId = extractMessageId(channelContext);
|
|
2842
|
-
|
|
4319
|
+
let registered = false;
|
|
4320
|
+
const registerOnce = () => {
|
|
4321
|
+
if (registered) return;
|
|
4322
|
+
registered = true;
|
|
4323
|
+
pi.registerTool(buildTool(messageId));
|
|
4324
|
+
log$1.info({ event: "subagent_enabled" }, "subagent tool registered");
|
|
4325
|
+
};
|
|
4326
|
+
pi.on("session_start", () => {
|
|
4327
|
+
registerOnce();
|
|
4328
|
+
});
|
|
4329
|
+
};
|
|
4330
|
+
}
|
|
4331
|
+
//#endregion
|
|
4332
|
+
//#region src/extensions/proactive-result.ts
|
|
4333
|
+
const proactiveShadowTool = {
|
|
4334
|
+
name: "get_latest_proactive_shadow_result",
|
|
4335
|
+
label: "Review proactive shadow result",
|
|
4336
|
+
description: "Use when your creator or an organization owner/admin asks what you most recently considered doing proactively or why. Returns the latest completed scheduled shadow evaluation, which is hypothetical and was not delivered. Explain the stored result faithfully without claiming that its proposed action or message was performed or sent.",
|
|
4337
|
+
parameters: Type.Object({}, { additionalProperties: false })
|
|
4338
|
+
};
|
|
4339
|
+
const contextSchema = z.object({
|
|
4340
|
+
proactiveAction: z.literal(true).optional(),
|
|
4341
|
+
runId: z.string().uuid(),
|
|
4342
|
+
messageId: z.string().uuid()
|
|
4343
|
+
});
|
|
4344
|
+
function createProactiveResultExtension({ channelContext }) {
|
|
4345
|
+
return (pi) => {
|
|
4346
|
+
let context;
|
|
4347
|
+
try {
|
|
4348
|
+
context = contextSchema.safeParse(JSON.parse(channelContext ?? ""));
|
|
4349
|
+
} catch (_error) {
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
if (!context.success) return;
|
|
4353
|
+
const { proactiveAction, runId, messageId } = context.data;
|
|
4354
|
+
let registered = false;
|
|
2843
4355
|
pi.on("session_start", async () => {
|
|
2844
|
-
if (
|
|
2845
|
-
|
|
2846
|
-
|
|
4356
|
+
if (registered) return;
|
|
4357
|
+
const client = sandboxClient();
|
|
4358
|
+
if (!client) return;
|
|
4359
|
+
if (!proactiveAction) {
|
|
4360
|
+
pi.registerTool({
|
|
4361
|
+
...proactiveShadowTool,
|
|
4362
|
+
async execute(_toolCallId, _params, signal) {
|
|
4363
|
+
try {
|
|
4364
|
+
const response = await client["proactive-result"]["latest-shadow"].$get({ query: {
|
|
4365
|
+
runId,
|
|
4366
|
+
messageId
|
|
4367
|
+
} }, { init: { signal } });
|
|
4368
|
+
if (response.status === 404) return {
|
|
4369
|
+
content: [{
|
|
4370
|
+
type: "text",
|
|
4371
|
+
text: "Proactive shadow result review is not available yet."
|
|
4372
|
+
}],
|
|
4373
|
+
isError: false,
|
|
4374
|
+
details: {}
|
|
4375
|
+
};
|
|
4376
|
+
if (!response.ok) throw new Error(`proactive shadow result lookup failed: ${response.status}`);
|
|
4377
|
+
const { result } = await response.json();
|
|
4378
|
+
if (!result) return {
|
|
4379
|
+
content: [{
|
|
4380
|
+
type: "text",
|
|
4381
|
+
text: "No completed scheduled shadow result is available to this conversation."
|
|
4382
|
+
}],
|
|
4383
|
+
isError: false,
|
|
4384
|
+
details: {}
|
|
4385
|
+
};
|
|
4386
|
+
return {
|
|
4387
|
+
content: [{
|
|
4388
|
+
type: "text",
|
|
4389
|
+
text: JSON.stringify({
|
|
4390
|
+
result,
|
|
4391
|
+
reminder: "This was a shadow evaluation. Nothing in it was performed or delivered."
|
|
4392
|
+
})
|
|
4393
|
+
}],
|
|
4394
|
+
isError: false,
|
|
4395
|
+
details: {}
|
|
4396
|
+
};
|
|
4397
|
+
} catch (err) {
|
|
4398
|
+
logger.warn({ err }, "proactive shadow result lookup failed");
|
|
4399
|
+
return {
|
|
4400
|
+
content: [{
|
|
4401
|
+
type: "text",
|
|
4402
|
+
text: "The proactive shadow result could not be retrieved."
|
|
4403
|
+
}],
|
|
4404
|
+
isError: true,
|
|
4405
|
+
details: {}
|
|
4406
|
+
};
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
});
|
|
4410
|
+
registered = true;
|
|
4411
|
+
return;
|
|
4412
|
+
}
|
|
4413
|
+
try {
|
|
4414
|
+
const response = await client["proactive-result"].$get({ query: {
|
|
4415
|
+
runId,
|
|
4416
|
+
messageId
|
|
4417
|
+
} });
|
|
4418
|
+
if (response.status === 404) return;
|
|
4419
|
+
if (!response.ok) throw new Error(`proactive result discovery failed: ${response.status}`);
|
|
4420
|
+
const { tool } = await response.json();
|
|
4421
|
+
if (!tool) return;
|
|
4422
|
+
pi.registerTool({
|
|
4423
|
+
name: tool.name,
|
|
4424
|
+
label: "Submit proactive result",
|
|
4425
|
+
description: tool.description,
|
|
4426
|
+
parameters: Type.Unsafe(tool.parameters),
|
|
4427
|
+
async execute(_toolCallId, result, signal) {
|
|
4428
|
+
const submitted = await client["proactive-result"].$post({ json: {
|
|
4429
|
+
runId,
|
|
4430
|
+
messageId,
|
|
4431
|
+
result
|
|
4432
|
+
} }, { init: { signal } });
|
|
4433
|
+
return {
|
|
4434
|
+
content: [{
|
|
4435
|
+
type: "text",
|
|
4436
|
+
text: await submitted.text()
|
|
4437
|
+
}],
|
|
4438
|
+
isError: !submitted.ok,
|
|
4439
|
+
details: {}
|
|
4440
|
+
};
|
|
4441
|
+
}
|
|
4442
|
+
});
|
|
4443
|
+
registered = true;
|
|
4444
|
+
} catch (err) {
|
|
4445
|
+
logger.warn({
|
|
4446
|
+
err,
|
|
4447
|
+
runId
|
|
4448
|
+
}, "proactive result tool unavailable; platform CLI submission remains available");
|
|
2847
4449
|
}
|
|
2848
4450
|
});
|
|
2849
4451
|
};
|
|
@@ -2851,10 +4453,13 @@ function createSubagentExtension({ channelContext }) {
|
|
|
2851
4453
|
//#endregion
|
|
2852
4454
|
//#region src/extensions/tool-call-env.ts
|
|
2853
4455
|
const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
|
|
4456
|
+
function shellQuoteValue(value) {
|
|
4457
|
+
return quote([value]);
|
|
4458
|
+
}
|
|
2854
4459
|
function withToolCallId({ command, toolCallId }) {
|
|
2855
|
-
return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
|
|
4460
|
+
return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
|
|
2856
4461
|
}
|
|
2857
|
-
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
|
|
4462
|
+
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
|
|
2858
4463
|
function stripPlatformExportsForDisplay(command) {
|
|
2859
4464
|
let c = command;
|
|
2860
4465
|
let m;
|
|
@@ -2872,214 +4477,6 @@ const toolCallEnvExtension = (pi) => {
|
|
|
2872
4477
|
});
|
|
2873
4478
|
};
|
|
2874
4479
|
//#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
4480
|
//#region src/extensions/background-tasks.ts
|
|
3084
4481
|
/**
|
|
3085
4482
|
* Background bash tasks as a pi extension.
|
|
@@ -3119,21 +4516,43 @@ const toolCallSummaryExtension = (pi) => {
|
|
|
3119
4516
|
* from the origin messageId in its channel context (`resolveConversationFromApi`)
|
|
3120
4517
|
* — and every run of the same conversation resolves to the same id, keeping the
|
|
3121
4518
|
* shared map correctly scoped across turns. `bg_*`, the completion wake, and the
|
|
3122
|
-
* next-session injection all filter
|
|
3123
|
-
*
|
|
4519
|
+
* next-session injection all filter by a **scope key** — the resolved
|
|
4520
|
+
* conversation id, or, when a session's conversation is unresolvable (a bare
|
|
4521
|
+
* CLI session, or a run whose channel-context ref carries no messageId), a
|
|
4522
|
+
* sentinel unique to that one session instance. Comparing on the raw
|
|
4523
|
+
* `conversationId` would bucket every unresolvable session together under
|
|
4524
|
+
* `null` and leak one's completion wake / status / next-session injection into
|
|
4525
|
+
* another; the sentinel keeps each isolated so an agent never sees or is woken
|
|
4526
|
+
* by a task from a different chat. Only the output log
|
|
3124
4527
|
* spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
|
|
3125
4528
|
* in memory; exit code and run state live on the in-memory task.
|
|
3126
4529
|
*
|
|
3127
|
-
* **
|
|
3128
|
-
*
|
|
3129
|
-
* respawn,
|
|
3130
|
-
*
|
|
3131
|
-
*
|
|
3132
|
-
*
|
|
3133
|
-
*
|
|
3134
|
-
*
|
|
3135
|
-
*
|
|
3136
|
-
*
|
|
4530
|
+
* **Cross-restart survival is OPT-IN, via `bg_run({ resumable: true })`.**
|
|
4531
|
+
* A non-resumable task's state lives only in the running harness process: a
|
|
4532
|
+
* harness restart (crash → supervisord respawn, `platform harness reload`, or
|
|
4533
|
+
* a sandbox recycle on idle timeout / redeploy / template rebuild) drops the
|
|
4534
|
+
* map and pi's exec children are reaped with it, and the task is gone — the
|
|
4535
|
+
* right behavior for a one-shot side-effecting command, which must never
|
|
4536
|
+
* silently re-run.
|
|
4537
|
+
*
|
|
4538
|
+
* A RESUMABLE task additionally checkpoints its *spec* (command, cwd, labels,
|
|
4539
|
+
* origin messageId — not its live output/exit state) to a durable, api-side
|
|
4540
|
+
* journal keyed by conversation (`/sandbox/bg-task-journal`, redis). On the
|
|
4541
|
+
* NEXT run's `session_start` — which usually lands on a *different*,
|
|
4542
|
+
* cold-provisioned sandbox, which is exactly why the journal is api-side and
|
|
4543
|
+
* not on the sandbox disk — the harness lists the journal and relaunches any
|
|
4544
|
+
* spec it isn't already running, keeping the original id and prepending a
|
|
4545
|
+
* `<resumed-after-restart>` banner so the agent knows it re-ran from scratch,
|
|
4546
|
+
* not continued. The spec is dropped from the journal when the task
|
|
4547
|
+
* finishes/kills. Because relaunch RE-EXECUTES the command, resumable is only
|
|
4548
|
+
* for idempotent, long-lived work (pollers, watchers, retry loops); the tool
|
|
4549
|
+
* description enforces this and the default is false.
|
|
4550
|
+
*
|
|
4551
|
+
* The idle-completion wake (a task finishing while the agent is idle) crosses
|
|
4552
|
+
* the sandbox → platform boundary via a fresh run (`bg-task-done`) for both
|
|
4553
|
+
* resumable and non-resumable tasks; the journal is a separate, additive layer
|
|
4554
|
+
* that only handles a task whose harness dies BEFORE it finishes. This stays
|
|
4555
|
+
* distinct from the scheduled-run (cron) system.
|
|
3137
4556
|
*/
|
|
3138
4557
|
const log = logger.child({ module: "background-tasks-ext" });
|
|
3139
4558
|
const ops = createLocalBashOperations();
|
|
@@ -3144,6 +4563,7 @@ const WATCHDOG_INTERVAL_MS = 3e4;
|
|
|
3144
4563
|
const KEEPALIVE_EVERY_MS = 6e4;
|
|
3145
4564
|
const KEEPALIVE_MAX_MS = 3600 * 1e3;
|
|
3146
4565
|
const STALL_HINT_AFTER_MS = 120 * 1e3;
|
|
4566
|
+
const PUBLISH_DEBOUNCE_MS = 300;
|
|
3147
4567
|
const MAX_LOG_BYTES = 100 * 1024 * 1024;
|
|
3148
4568
|
const DEFAULT_TAIL_LINES = 30;
|
|
3149
4569
|
const TAIL_READ_BYTES = 64 * 1024;
|
|
@@ -3151,11 +4571,12 @@ function taskLabel(meta) {
|
|
|
3151
4571
|
return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
|
|
3152
4572
|
}
|
|
3153
4573
|
let taskCounter = 0;
|
|
4574
|
+
let sessionScopeCounter = 0;
|
|
3154
4575
|
const tasks = /* @__PURE__ */ new Map();
|
|
3155
4576
|
let watchdogInterval = null;
|
|
3156
4577
|
let lastKeepaliveAt = 0;
|
|
3157
|
-
function
|
|
3158
|
-
return meta.
|
|
4578
|
+
function sameScope(meta, scopeKey) {
|
|
4579
|
+
return meta.scopeKey === scopeKey;
|
|
3159
4580
|
}
|
|
3160
4581
|
function logPath(id) {
|
|
3161
4582
|
return join(tasksDir(), `${id}.log`);
|
|
@@ -3268,6 +4689,10 @@ function createBackgroundTasksExtension({ channelContext }) {
|
|
|
3268
4689
|
return id;
|
|
3269
4690
|
});
|
|
3270
4691
|
}
|
|
4692
|
+
const unresolvedScopeSentinel = `unresolved:${process.pid.toString(36)}:${(sessionScopeCounter += 1).toString(36)}`;
|
|
4693
|
+
function scopeKey() {
|
|
4694
|
+
return conversationId ?? unresolvedScopeSentinel;
|
|
4695
|
+
}
|
|
3271
4696
|
let agentActive = false;
|
|
3272
4697
|
pi.on("agent_start", async () => {
|
|
3273
4698
|
agentActive = true;
|
|
@@ -3295,13 +4720,16 @@ ${recentOutput}
|
|
|
3295
4720
|
</background-task-finished>
|
|
3296
4721
|
Run bg_logs for the full output.
|
|
3297
4722
|
|
|
3298
|
-
This is a background-task completion, not a message from the user.
|
|
4723
|
+
This is a background-task completion, not a message from the user.
|
|
4724
|
+
For a routine or expected completion, output nothing.
|
|
4725
|
+
Only reply if the outcome changes what the user should know or do,
|
|
4726
|
+
or if you were explicitly waiting to report it.`,
|
|
3299
4727
|
display: false
|
|
3300
4728
|
};
|
|
3301
4729
|
}
|
|
3302
4730
|
async function notifyCompletion(meta) {
|
|
3303
4731
|
if (meta.notified) return;
|
|
3304
|
-
if (agentActive &&
|
|
4732
|
+
if (agentActive && sameScope(meta, scopeKey())) {
|
|
3305
4733
|
meta.notified = true;
|
|
3306
4734
|
pi.sendMessage(await taskDoneMessage(meta), {
|
|
3307
4735
|
triggerTurn: true,
|
|
@@ -3332,9 +4760,83 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3332
4760
|
});
|
|
3333
4761
|
} else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
|
|
3334
4762
|
}
|
|
3335
|
-
|
|
4763
|
+
const SNAPSHOT_TAIL_LINES = 40;
|
|
4764
|
+
let lastPublishedSignature = null;
|
|
4765
|
+
let publishInFlight = null;
|
|
4766
|
+
let publishQueued = false;
|
|
4767
|
+
function snapshotSignature(snapshot) {
|
|
4768
|
+
return JSON.stringify(snapshot.map((t) => ({
|
|
4769
|
+
id: t.id,
|
|
4770
|
+
state: t.state,
|
|
4771
|
+
exitCode: t.exitCode,
|
|
4772
|
+
killedReason: t.killedReason,
|
|
4773
|
+
outputTail: t.outputTail
|
|
4774
|
+
})));
|
|
4775
|
+
}
|
|
4776
|
+
async function doPublishSnapshot() {
|
|
4777
|
+
if (!messageId) return;
|
|
4778
|
+
const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
|
|
4779
|
+
try {
|
|
4780
|
+
const snapshot = await Promise.all(mine.map(async (t) => ({
|
|
4781
|
+
id: t.id,
|
|
4782
|
+
command: t.command,
|
|
4783
|
+
description: t.description,
|
|
4784
|
+
startedAt: t.startedAt,
|
|
4785
|
+
state: t.running ? "running" : "finished",
|
|
4786
|
+
exitCode: t.exitCode,
|
|
4787
|
+
killedReason: t.killedReason,
|
|
4788
|
+
outputTail: await tailLog(t.id, SNAPSHOT_TAIL_LINES)
|
|
4789
|
+
})));
|
|
4790
|
+
const signature = snapshotSignature(snapshot);
|
|
4791
|
+
if (signature === lastPublishedSignature) return;
|
|
4792
|
+
lastPublishedSignature = signature;
|
|
4793
|
+
postBackgroundTasksSnapshot({
|
|
4794
|
+
messageId,
|
|
4795
|
+
tasks: snapshot
|
|
4796
|
+
});
|
|
4797
|
+
} catch (err) {
|
|
4798
|
+
log.debug({
|
|
4799
|
+
err,
|
|
4800
|
+
event: "bg_tasks_snapshot_build_failed"
|
|
4801
|
+
}, "building bg-tasks snapshot failed");
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
async function publishSnapshotNow() {
|
|
4805
|
+
if (publishInFlight) {
|
|
4806
|
+
publishQueued = true;
|
|
4807
|
+
return;
|
|
4808
|
+
}
|
|
4809
|
+
publishInFlight = (async () => {
|
|
4810
|
+
try {
|
|
4811
|
+
do {
|
|
4812
|
+
publishQueued = false;
|
|
4813
|
+
await doPublishSnapshot();
|
|
4814
|
+
} while (publishQueued);
|
|
4815
|
+
} finally {
|
|
4816
|
+
publishInFlight = null;
|
|
4817
|
+
}
|
|
4818
|
+
})();
|
|
4819
|
+
await publishInFlight;
|
|
4820
|
+
}
|
|
4821
|
+
let publishTimer = null;
|
|
4822
|
+
function schedulePublishSnapshot() {
|
|
4823
|
+
if (publishTimer) return;
|
|
4824
|
+
publishTimer = setTimeout(() => {
|
|
4825
|
+
publishTimer = null;
|
|
4826
|
+
publishSnapshotNow();
|
|
4827
|
+
}, PUBLISH_DEBOUNCE_MS);
|
|
4828
|
+
publishTimer.unref?.();
|
|
4829
|
+
}
|
|
4830
|
+
async function flushPublishSnapshot() {
|
|
4831
|
+
if (publishTimer) {
|
|
4832
|
+
clearTimeout(publishTimer);
|
|
4833
|
+
publishTimer = null;
|
|
4834
|
+
}
|
|
4835
|
+
await publishSnapshotNow();
|
|
4836
|
+
}
|
|
4837
|
+
async function launchTask({ command, description, cwd, resumable = false, resumedFromJournal = false, id: providedId, startedAt: providedStartedAt }) {
|
|
3336
4838
|
taskCounter += 1;
|
|
3337
|
-
const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
|
|
4839
|
+
const id = providedId ?? `bg-${process.pid.toString(36)}-${taskCounter}`;
|
|
3338
4840
|
try {
|
|
3339
4841
|
await mkdir(tasksDir(), { recursive: true });
|
|
3340
4842
|
} catch (err) {
|
|
@@ -3350,7 +4852,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3350
4852
|
taskId: id
|
|
3351
4853
|
}, "bg task log write failed");
|
|
3352
4854
|
});
|
|
3353
|
-
const startedAt = Date.now();
|
|
4855
|
+
const startedAt = providedStartedAt ?? Date.now();
|
|
3354
4856
|
const meta = {
|
|
3355
4857
|
id,
|
|
3356
4858
|
command: stripPlatformExportsForDisplay(command),
|
|
@@ -3358,6 +4860,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3358
4860
|
logBytes: 0,
|
|
3359
4861
|
lastOutputAt: startedAt,
|
|
3360
4862
|
conversationId,
|
|
4863
|
+
scopeKey: scopeKey(),
|
|
3361
4864
|
messageId,
|
|
3362
4865
|
description,
|
|
3363
4866
|
notified: false,
|
|
@@ -3365,9 +4868,23 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3365
4868
|
controller: new AbortController(),
|
|
3366
4869
|
running: true,
|
|
3367
4870
|
exitCode: null,
|
|
3368
|
-
error: null
|
|
4871
|
+
error: null,
|
|
4872
|
+
resumable,
|
|
4873
|
+
cwd,
|
|
4874
|
+
resumedFromJournal
|
|
3369
4875
|
};
|
|
3370
4876
|
tasks.set(id, meta);
|
|
4877
|
+
if (resumable && messageId) putBackgroundTaskJournalSpec({
|
|
4878
|
+
messageId,
|
|
4879
|
+
spec: {
|
|
4880
|
+
id,
|
|
4881
|
+
command,
|
|
4882
|
+
cwd,
|
|
4883
|
+
description,
|
|
4884
|
+
startedAt,
|
|
4885
|
+
messageId
|
|
4886
|
+
}
|
|
4887
|
+
});
|
|
3371
4888
|
ops.exec(command, cwd, {
|
|
3372
4889
|
onData: (chunk) => {
|
|
3373
4890
|
meta.logBytes += chunk.length;
|
|
@@ -3393,6 +4910,11 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3393
4910
|
taskId: id,
|
|
3394
4911
|
exitCode: meta.exitCode
|
|
3395
4912
|
}, "bg task finished");
|
|
4913
|
+
if (meta.resumable && meta.messageId) deleteBackgroundTaskJournalSpec({
|
|
4914
|
+
messageId: meta.messageId,
|
|
4915
|
+
taskId: id
|
|
4916
|
+
});
|
|
4917
|
+
schedulePublishSnapshot();
|
|
3396
4918
|
await notifyCompletion(meta);
|
|
3397
4919
|
});
|
|
3398
4920
|
ensureWatchdog();
|
|
@@ -3400,19 +4922,55 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3400
4922
|
taskId: id,
|
|
3401
4923
|
conversationId
|
|
3402
4924
|
}, "bg task started");
|
|
4925
|
+
schedulePublishSnapshot();
|
|
3403
4926
|
return meta;
|
|
3404
4927
|
}
|
|
3405
4928
|
function knownTaskIds() {
|
|
3406
|
-
return [...tasks.values()].filter((t) =>
|
|
4929
|
+
return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
|
|
4930
|
+
}
|
|
4931
|
+
let journalChecked = false;
|
|
4932
|
+
async function rehydrateJournaledTasks() {
|
|
4933
|
+
if (!messageId || journalChecked) return;
|
|
4934
|
+
journalChecked = true;
|
|
4935
|
+
const specs = await listBackgroundTaskJournalSpecs({ messageId });
|
|
4936
|
+
if (specs.length === 0) return;
|
|
4937
|
+
for (const spec of specs) {
|
|
4938
|
+
const live = tasks.get(spec.id);
|
|
4939
|
+
if (live && sameScope(live, scopeKey())) continue;
|
|
4940
|
+
log.info({
|
|
4941
|
+
taskId: spec.id,
|
|
4942
|
+
conversationId
|
|
4943
|
+
}, "relaunching journaled resumable bg task after restart");
|
|
4944
|
+
const meta = await launchTask({
|
|
4945
|
+
command: spec.command,
|
|
4946
|
+
description: spec.description,
|
|
4947
|
+
cwd: spec.cwd,
|
|
4948
|
+
resumable: true,
|
|
4949
|
+
resumedFromJournal: true,
|
|
4950
|
+
id: spec.id,
|
|
4951
|
+
startedAt: spec.startedAt
|
|
4952
|
+
});
|
|
4953
|
+
try {
|
|
4954
|
+
const stream = createWriteStream(logPath(meta.id), { flags: "a" });
|
|
4955
|
+
stream.write(`<resumed-after-restart>requeued and relaunched on a new sandbox after the previous one was drained; this is a fresh execution of the command from the start, not a continuation</resumed-after-restart>\n`);
|
|
4956
|
+
stream.end();
|
|
4957
|
+
} catch (err) {
|
|
4958
|
+
log.debug({
|
|
4959
|
+
err,
|
|
4960
|
+
taskId: meta.id
|
|
4961
|
+
}, "resume banner write failed");
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
3407
4964
|
}
|
|
3408
4965
|
pi.on("session_start", async () => {
|
|
3409
|
-
if (tasks.size === 0) return;
|
|
4966
|
+
if (tasks.size === 0 && (!messageId || journalChecked)) return;
|
|
3410
4967
|
await ensureConversationId();
|
|
3411
|
-
|
|
4968
|
+
await rehydrateJournaledTasks();
|
|
4969
|
+
for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
|
|
3412
4970
|
tasks.delete(id);
|
|
3413
4971
|
await unlink(logPath(id)).catch(() => {});
|
|
3414
4972
|
}
|
|
3415
|
-
const unnotified = [...tasks.values()].filter((t) =>
|
|
4973
|
+
const unnotified = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && !t.notified && !t.running);
|
|
3416
4974
|
for (const meta of unnotified) {
|
|
3417
4975
|
meta.notified = true;
|
|
3418
4976
|
pi.sendMessage(await taskDoneMessage(meta));
|
|
@@ -3421,7 +4979,9 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3421
4979
|
conversationId,
|
|
3422
4980
|
count: unnotified.length
|
|
3423
4981
|
}, "injected completed bg tasks at session_start");
|
|
3424
|
-
if ([...tasks.values()].some((t) =>
|
|
4982
|
+
if ([...tasks.values()].some((t) => sameScope(t, scopeKey()) && t.running)) ensureWatchdog();
|
|
4983
|
+
lastPublishedSignature = null;
|
|
4984
|
+
await flushPublishSnapshot();
|
|
3425
4985
|
});
|
|
3426
4986
|
function err(text) {
|
|
3427
4987
|
return {
|
|
@@ -3438,11 +4998,11 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3438
4998
|
}
|
|
3439
4999
|
function resolveTask(taskId) {
|
|
3440
5000
|
const exact = tasks.get(taskId);
|
|
3441
|
-
if (exact &&
|
|
5001
|
+
if (exact && sameScope(exact, scopeKey())) return {
|
|
3442
5002
|
error: null,
|
|
3443
5003
|
meta: exact
|
|
3444
5004
|
};
|
|
3445
|
-
const matches = [...tasks.values()].filter((t) =>
|
|
5005
|
+
const matches = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && t.id.startsWith(taskId));
|
|
3446
5006
|
if (matches.length === 1) return {
|
|
3447
5007
|
error: null,
|
|
3448
5008
|
meta: matches[0]
|
|
@@ -3451,7 +5011,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3451
5011
|
return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
|
|
3452
5012
|
}
|
|
3453
5013
|
function listTasks() {
|
|
3454
|
-
const mine = [...tasks.values()].filter((t) =>
|
|
5014
|
+
const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
|
|
3455
5015
|
if (mine.length === 0) return "No background tasks.";
|
|
3456
5016
|
return mine.map((t) => {
|
|
3457
5017
|
const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
|
|
@@ -3464,24 +5024,27 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3464
5024
|
const bgRun = {
|
|
3465
5025
|
name: "bg_run",
|
|
3466
5026
|
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",
|
|
5027
|
+
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. Pass resumable:true ONLY for an idempotent, long-lived command (a poller/watcher/retry loop) that should be requeued and relaunched on your next run if the sandbox goes away before it finishes (a sandbox is drained and replaced with a fresh one, not restarted in place) — the requeue re-runs the command from scratch, so never mark a one-shot side-effecting job (a migration, an apply, a send) resumable.",
|
|
5028
|
+
promptSnippet: "bg_run — run a long command without blocking; you are notified on completion (resumable:true requeues the task onto a fresh sandbox if the current one is drained, idempotent commands only)",
|
|
3469
5029
|
parameters: Type.Object({
|
|
3470
5030
|
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)." }))
|
|
5031
|
+
description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." })),
|
|
5032
|
+
resumable: Type.Optional(Type.Boolean({ description: "When true, this task is requeued and relaunched on your next run if the sandbox it is running on goes away before it finishes (a sandbox is drained and replaced with a fresh one, rather than restarted in place, so an in-flight task would otherwise be lost). Use ONLY for idempotent, long-lived commands (pollers, watchers, retry loops) — the requeue re-runs the command from scratch on the new sandbox, so never set this on a one-shot side-effecting command." }))
|
|
3472
5033
|
}),
|
|
3473
5034
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3474
5035
|
await ensureConversationId();
|
|
3475
|
-
const { command, description = null } = params;
|
|
5036
|
+
const { command, description = null, resumable = false } = params;
|
|
3476
5037
|
const meta = await launchTask({
|
|
3477
5038
|
command,
|
|
3478
5039
|
description,
|
|
3479
|
-
cwd: ctx.cwd
|
|
5040
|
+
cwd: ctx.cwd,
|
|
5041
|
+
resumable
|
|
3480
5042
|
});
|
|
5043
|
+
const resumeNote = resumable && meta.messageId ? "\nResumable: if this sandbox is drained before the task finishes, it will be requeued and relaunched from the start on your next run." : resumable ? "\nNote: resumable was requested but this session has no durable conversation, so it will NOT be requeued if the sandbox is drained." : "";
|
|
3481
5044
|
return {
|
|
3482
5045
|
content: [{
|
|
3483
5046
|
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}"}.`
|
|
5047
|
+
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}"}.${resumeNote}\n\nProgress is already visible to the user. Output nothing unless the user asked for an update.`
|
|
3485
5048
|
}],
|
|
3486
5049
|
details: {}
|
|
3487
5050
|
};
|
|
@@ -3510,7 +5073,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3510
5073
|
return {
|
|
3511
5074
|
content: [{
|
|
3512
5075
|
type: "text",
|
|
3513
|
-
text: await describeStatus(resolved.meta, lines)
|
|
5076
|
+
text: `${await describeStatus(resolved.meta, lines)}${resolved.meta.running ? "\n\nProgress is already visible to the user. Output nothing unless the user asked for an update." : ""}`
|
|
3514
5077
|
}],
|
|
3515
5078
|
details: {}
|
|
3516
5079
|
};
|
|
@@ -3601,6 +5164,7 @@ const all = [
|
|
|
3601
5164
|
localToolsExtension,
|
|
3602
5165
|
toolCallEnvExtension,
|
|
3603
5166
|
bashDefaultTimeoutExtension,
|
|
5167
|
+
diskGuardExtension,
|
|
3604
5168
|
toolCallSummaryExtension
|
|
3605
5169
|
];
|
|
3606
5170
|
/**
|
|
@@ -3611,6 +5175,7 @@ const all = [
|
|
|
3611
5175
|
*/
|
|
3612
5176
|
function platformExtensions({ sessionId, channelContext }) {
|
|
3613
5177
|
return [
|
|
5178
|
+
providerApiCompatExtension,
|
|
3614
5179
|
createPlatformExtensions({
|
|
3615
5180
|
sessionId,
|
|
3616
5181
|
channelContext
|
|
@@ -3619,8 +5184,10 @@ function platformExtensions({ sessionId, channelContext }) {
|
|
|
3619
5184
|
selfTraceExtension,
|
|
3620
5185
|
createBackgroundTasksExtension({ channelContext }),
|
|
3621
5186
|
createSubagentExtension({ channelContext }),
|
|
3622
|
-
|
|
5187
|
+
createProactiveResultExtension({ channelContext }),
|
|
5188
|
+
createContextManagementExtension(),
|
|
5189
|
+
resourcePressureWarningExtension
|
|
3623
5190
|
];
|
|
3624
5191
|
}
|
|
3625
5192
|
//#endregion
|
|
3626
|
-
export { all, createHarness, createHealthHandler, createPlatformEnvMiddleware, installToolUpdateAutoStop, isPlatformConfigLoaded, loadPlatformConfig, localToolsExtension, mcp_default as mcpExtension, memoryExtension, platformExtensions, runToolUpdateLoop, soulExtension, toolCallEnvExtension };
|
|
5193
|
+
export { all, createHarness, createHealthHandler, createPlatformEnvMiddleware, installToolUpdateAutoStop, isPlatformConfigLoaded, loadPlatformConfig, localToolsExtension, mcp_default as mcpExtension, memoryExtension, platformExtensions, providerApiCompatExtension, runClosingTextLoop, runToolUpdateLoop, soulExtension, toolCallEnvExtension, turnEndedWithoutVisibleText };
|