@skydiveai/pi-extensions 0.1.0-beta.18 → 0.1.0-beta.1801
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +1503 -492
- package/package.json +3 -9
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
|
|
3
3
|
import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
|
|
4
4
|
import { buildAgentCard, chainMiddleware, composeHandlers, createAgentExecutor, createProtocolHandlers, getCurrentTraceparent, logger, mountAt, requestHeaders, requestUrl, webHandlerToMiddleware } from "@skydiveai/pi-server";
|
|
5
|
-
import { mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
@@ -19,8 +19,12 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
|
19
19
|
import { Resource } from "@opentelemetry/resources";
|
|
20
20
|
import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
21
21
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { availableParallelism } from "node:os";
|
|
24
|
+
import { promisify } from "node:util";
|
|
22
25
|
import { hc } from "hono/client";
|
|
23
26
|
import { parse } from "yaml";
|
|
27
|
+
import { quote } from "shell-quote";
|
|
24
28
|
import { createWriteStream } from "node:fs";
|
|
25
29
|
import { finished } from "node:stream/promises";
|
|
26
30
|
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
@@ -255,7 +259,7 @@ function createHealthHandler({ metadata }) {
|
|
|
255
259
|
* read on the hot path before every LLM call), it falls back to the default
|
|
256
260
|
* for that knob and logs once.
|
|
257
261
|
*/
|
|
258
|
-
const log$
|
|
262
|
+
const log$15 = logger.child({ module: "context-management-config" });
|
|
259
263
|
const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
|
|
260
264
|
enabled: false,
|
|
261
265
|
perResultMaxBytes: 16 * 1024,
|
|
@@ -303,7 +307,7 @@ function resolveContextManagementConfig(env = process.env) {
|
|
|
303
307
|
maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
|
|
304
308
|
});
|
|
305
309
|
if (!parsed.success) {
|
|
306
|
-
log$
|
|
310
|
+
log$15.warn({
|
|
307
311
|
event: "context_management_config_invalid",
|
|
308
312
|
err: parsed.error
|
|
309
313
|
}, "falling back to default context-management config");
|
|
@@ -414,6 +418,275 @@ function installIterationCap({ session, log }, configOverride = null) {
|
|
|
414
418
|
*/
|
|
415
419
|
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
420
|
//#endregion
|
|
421
|
+
//#region src/extensions/tool-call-summary.ts
|
|
422
|
+
const log$14 = logger.child({ module: "tool-call-summary-extension" });
|
|
423
|
+
/**
|
|
424
|
+
* The injected parameter name: a namespaced sentinel, so it can never collide
|
|
425
|
+
* with a real tool argument and is unmistakable in transcripts and logs. The
|
|
426
|
+
* frontend renderer (ANY-2723) duplicates this literal — keep the two in sync.
|
|
427
|
+
*/
|
|
428
|
+
const TOOL_CALL_SUMMARY_FIELD = "__skydive_summary__";
|
|
429
|
+
/** JSON Schema fragment for the injected parameter. */
|
|
430
|
+
const SUMMARY_PROPERTY = {
|
|
431
|
+
type: "string",
|
|
432
|
+
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\"."
|
|
433
|
+
};
|
|
434
|
+
const jsonSchemaObjectSchema = z.object({
|
|
435
|
+
type: z.unknown().optional(),
|
|
436
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
437
|
+
required: z.array(z.string()).optional(),
|
|
438
|
+
additionalProperties: z.unknown().optional()
|
|
439
|
+
}).passthrough();
|
|
440
|
+
const toolEntrySchema = z.object({
|
|
441
|
+
name: z.string().optional(),
|
|
442
|
+
input_schema: jsonSchemaObjectSchema.optional(),
|
|
443
|
+
parameters: jsonSchemaObjectSchema.optional(),
|
|
444
|
+
function: z.object({
|
|
445
|
+
name: z.string().optional(),
|
|
446
|
+
parameters: jsonSchemaObjectSchema.optional()
|
|
447
|
+
}).passthrough().optional()
|
|
448
|
+
}).passthrough();
|
|
449
|
+
const payloadWithToolsSchema = z.object({ tools: z.array(z.unknown()) }).passthrough();
|
|
450
|
+
/**
|
|
451
|
+
* Add the summary property to one JSON Schema object. Returns the augmented
|
|
452
|
+
* copy, or `null` when the tool should be left untouched: a strict schema
|
|
453
|
+
* (`additionalProperties: false`) whose validation would reject the extra
|
|
454
|
+
* field, or one that already declares a `__skydive_summary__` property of its own.
|
|
455
|
+
*/
|
|
456
|
+
function augmentSchema(schema) {
|
|
457
|
+
if (schema.additionalProperties === false) return null;
|
|
458
|
+
const properties = schema.properties ?? {};
|
|
459
|
+
if ("__skydive_summary__" in properties) return null;
|
|
460
|
+
const required = schema.required ?? [];
|
|
461
|
+
return {
|
|
462
|
+
...schema,
|
|
463
|
+
type: schema.type ?? "object",
|
|
464
|
+
properties: {
|
|
465
|
+
[TOOL_CALL_SUMMARY_FIELD]: SUMMARY_PROPERTY,
|
|
466
|
+
...properties
|
|
467
|
+
},
|
|
468
|
+
required: required.includes("__skydive_summary__") ? required : [...required, TOOL_CALL_SUMMARY_FIELD]
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Augment a single tool entry, dispatching on which provider shape it is.
|
|
473
|
+
* Returns the (possibly rebuilt) entry and whether anything changed. Skipped
|
|
474
|
+
* tools — wrong shape, strict, or name in `strictToolNames` — return unchanged.
|
|
475
|
+
*/
|
|
476
|
+
function augmentToolEntry(entry, strictToolNames) {
|
|
477
|
+
const parsed = toolEntrySchema.safeParse(entry);
|
|
478
|
+
if (!parsed.success) return {
|
|
479
|
+
entry,
|
|
480
|
+
changed: false
|
|
481
|
+
};
|
|
482
|
+
const tool = parsed.data;
|
|
483
|
+
const name = tool.name ?? tool.function?.name ?? null;
|
|
484
|
+
if (name !== null && strictToolNames.has(name)) return {
|
|
485
|
+
entry,
|
|
486
|
+
changed: false
|
|
487
|
+
};
|
|
488
|
+
if (tool.input_schema) {
|
|
489
|
+
const augmented = augmentSchema(tool.input_schema);
|
|
490
|
+
if (!augmented) return {
|
|
491
|
+
entry,
|
|
492
|
+
changed: false
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
entry: {
|
|
496
|
+
...tool,
|
|
497
|
+
input_schema: augmented
|
|
498
|
+
},
|
|
499
|
+
changed: true
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (tool.parameters) {
|
|
503
|
+
const augmented = augmentSchema(tool.parameters);
|
|
504
|
+
if (!augmented) return {
|
|
505
|
+
entry,
|
|
506
|
+
changed: false
|
|
507
|
+
};
|
|
508
|
+
return {
|
|
509
|
+
entry: {
|
|
510
|
+
...tool,
|
|
511
|
+
parameters: augmented
|
|
512
|
+
},
|
|
513
|
+
changed: true
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (tool.function?.parameters) {
|
|
517
|
+
const augmented = augmentSchema(tool.function.parameters);
|
|
518
|
+
if (!augmented) return {
|
|
519
|
+
entry,
|
|
520
|
+
changed: false
|
|
521
|
+
};
|
|
522
|
+
return {
|
|
523
|
+
entry: {
|
|
524
|
+
...tool,
|
|
525
|
+
function: {
|
|
526
|
+
...tool.function,
|
|
527
|
+
parameters: augmented
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
changed: true
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
entry,
|
|
535
|
+
changed: false
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Inject the summary field into every eligible tool in a provider payload.
|
|
540
|
+
* Returns a new payload when at least one tool was augmented, or `undefined`
|
|
541
|
+
* to signal "no change" (which keeps the original payload, per the
|
|
542
|
+
* `before_provider_request` contract).
|
|
543
|
+
*
|
|
544
|
+
* @param payload The outgoing provider payload (shape varies by provider).
|
|
545
|
+
* @param strictToolNames Names of tools whose registered schema is strict and
|
|
546
|
+
* must be skipped to avoid validation errors.
|
|
547
|
+
*/
|
|
548
|
+
function injectToolCallSummary(payload, strictToolNames) {
|
|
549
|
+
const parsed = payloadWithToolsSchema.safeParse(payload);
|
|
550
|
+
if (!parsed.success || parsed.data.tools.length === 0) return void 0;
|
|
551
|
+
let changed = false;
|
|
552
|
+
const tools = parsed.data.tools.map((entry) => {
|
|
553
|
+
const result = augmentToolEntry(entry, strictToolNames);
|
|
554
|
+
if (result.changed) changed = true;
|
|
555
|
+
return result.entry;
|
|
556
|
+
});
|
|
557
|
+
if (!changed) return void 0;
|
|
558
|
+
return {
|
|
559
|
+
...parsed.data,
|
|
560
|
+
tools
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Names of registered tools whose schema sets `additionalProperties: false`.
|
|
565
|
+
* Pi validates the model's tool args against this registered schema, so the
|
|
566
|
+
* injected field would make a strict tool's call fail validation — skip them.
|
|
567
|
+
*/
|
|
568
|
+
function getStrictToolNames(pi) {
|
|
569
|
+
const names = /* @__PURE__ */ new Set();
|
|
570
|
+
for (const tool of pi.getAllTools()) {
|
|
571
|
+
const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
|
|
572
|
+
if (parsed.success && parsed.data.additionalProperties === false) names.add(tool.name);
|
|
573
|
+
}
|
|
574
|
+
return names;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Whether a tool's own schema declares a `__skydive_summary__` property. We
|
|
578
|
+
* never inject into such a tool, so any value it carries is a real argument
|
|
579
|
+
* and must be left alone.
|
|
580
|
+
*/
|
|
581
|
+
function schemaDeclaresSummary(parameters) {
|
|
582
|
+
const parsed = jsonSchemaObjectSchema.safeParse(parameters);
|
|
583
|
+
return parsed.success && parsed.data.properties != null && "__skydive_summary__" in parsed.data.properties;
|
|
584
|
+
}
|
|
585
|
+
function toolDeclaresSummaryParam(pi, toolName) {
|
|
586
|
+
const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
|
|
587
|
+
if (!tool) return false;
|
|
588
|
+
return schemaDeclaresSummary(tool.parameters);
|
|
589
|
+
}
|
|
590
|
+
/** A copy of `args` without the injected summary. Never mutates its input. */
|
|
591
|
+
function withoutInjectedSummary(args) {
|
|
592
|
+
if (args == null || typeof args !== "object" || Array.isArray(args)) return args;
|
|
593
|
+
if (!("__skydive_summary__" in args)) return args;
|
|
594
|
+
const { [TOOL_CALL_SUMMARY_FIELD]: _summary, ...rest } = args;
|
|
595
|
+
return rest;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Register a tool so the injected summary is removed *before* pi validates
|
|
599
|
+
* the model's arguments against the tool's schema.
|
|
600
|
+
*
|
|
601
|
+
* Needed because the `tool_call` strip below runs too late. pi-agent-core's
|
|
602
|
+
* `prepareToolCall` goes: `tool.prepareArguments` → `validateToolArguments` →
|
|
603
|
+
* `beforeToolCall` (which is what dispatches `tool_call`). A tool whose schema
|
|
604
|
+
* sets `additionalProperties: false` therefore rejects the summary and errors
|
|
605
|
+
* the call — client-side, before any request reaches the server — while the
|
|
606
|
+
* strip meant to prevent exactly that sits one step further down.
|
|
607
|
+
*
|
|
608
|
+
* `augmentSchema` already skips strict tools, but that only controls what the
|
|
609
|
+
* model is *told*. It still emits the field on those tools, because every
|
|
610
|
+
* other tool in the list declares it as required for every call. So a server
|
|
611
|
+
* can connect, bind its tools, present a healthy inventory, and have every
|
|
612
|
+
* call fail — and it reads as the server's fault when it is ours.
|
|
613
|
+
*
|
|
614
|
+
* Apply to tools registered from schemas we do not author: MCP servers and
|
|
615
|
+
* local `tools/*.ts`. Tools built here with `Type.Object(...)` do not need it
|
|
616
|
+
* (TypeBox emits no `additionalProperties`, so the field validates fine and
|
|
617
|
+
* the `tool_call` strip removes it in time).
|
|
618
|
+
*
|
|
619
|
+
* Fails open, like the rest of this module: if the strip throws, the original
|
|
620
|
+
* arguments are used rather than failing the call.
|
|
621
|
+
*/
|
|
622
|
+
function withSummaryStrippedBeforeValidation(tool) {
|
|
623
|
+
if (schemaDeclaresSummary(tool.parameters)) return tool;
|
|
624
|
+
const toolPrepare = tool.prepareArguments;
|
|
625
|
+
const prepareArguments = ((args) => {
|
|
626
|
+
let stripped = args;
|
|
627
|
+
try {
|
|
628
|
+
stripped = withoutInjectedSummary(args);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
log$14.error({
|
|
631
|
+
err,
|
|
632
|
+
event: "tool_call_summary_prepare_strip_failed",
|
|
633
|
+
toolName: tool.name
|
|
634
|
+
}, "tool_call_summary pre-validation strip failed; leaving arguments untouched");
|
|
635
|
+
}
|
|
636
|
+
return toolPrepare ? toolPrepare(stripped) : stripped;
|
|
637
|
+
});
|
|
638
|
+
return {
|
|
639
|
+
...tool,
|
|
640
|
+
prepareArguments
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Remove the injected summary from a tool's execution input. No-op when the
|
|
645
|
+
* field is absent, or when the tool genuinely declares a `__skydive_summary__`
|
|
646
|
+
* parameter of its own (which we never inject into, so its value is real).
|
|
647
|
+
* Mutates `input` in place, matching the `tool_call` contract.
|
|
648
|
+
*
|
|
649
|
+
* Fails open: this runs on the critical path of tool execution, and the
|
|
650
|
+
* `getAllTools()` lookup can throw. On any error we leave `input` untouched
|
|
651
|
+
* (the sentinel may pass through to the tool, but a bug here can never break
|
|
652
|
+
* tool execution).
|
|
653
|
+
*/
|
|
654
|
+
function stripInjectedSummary(pi, toolName, input) {
|
|
655
|
+
try {
|
|
656
|
+
if (!("__skydive_summary__" in input)) return;
|
|
657
|
+
if (toolDeclaresSummaryParam(pi, toolName)) return;
|
|
658
|
+
delete input[TOOL_CALL_SUMMARY_FIELD];
|
|
659
|
+
} catch (err) {
|
|
660
|
+
log$14.error({
|
|
661
|
+
err,
|
|
662
|
+
event: "tool_call_summary_strip_failed",
|
|
663
|
+
toolName
|
|
664
|
+
}, "tool_call_summary strip failed; leaving tool input untouched");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Compute the rewritten payload for a `before_provider_request` event, failing
|
|
669
|
+
* open: on any error the original payload is left untouched so a bug here can
|
|
670
|
+
* never break an LLM call.
|
|
671
|
+
*/
|
|
672
|
+
function buildInjectedPayload(pi, payload) {
|
|
673
|
+
try {
|
|
674
|
+
return injectToolCallSummary(payload, getStrictToolNames(pi));
|
|
675
|
+
} catch (err) {
|
|
676
|
+
log$14.error({
|
|
677
|
+
err,
|
|
678
|
+
event: "tool_call_summary_injection_failed"
|
|
679
|
+
}, "tool_call_summary injection failed; passing payload through unchanged");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const toolCallSummaryExtension = (pi) => {
|
|
684
|
+
pi.on("before_provider_request", (event) => buildInjectedPayload(pi, event.payload));
|
|
685
|
+
pi.on("tool_call", (event) => {
|
|
686
|
+
stripInjectedSummary(pi, event.toolName, event.input);
|
|
687
|
+
});
|
|
688
|
+
};
|
|
689
|
+
//#endregion
|
|
417
690
|
//#region src/extensions/local-tools.ts
|
|
418
691
|
/**
|
|
419
692
|
* Local-tools adapter as a pi extension. Mirrors the mcp.ts hot-reload pattern
|
|
@@ -437,7 +710,7 @@ const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task i
|
|
|
437
710
|
* or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
|
|
438
711
|
* `tools/_example.ts` documents the shape without registering.
|
|
439
712
|
*/
|
|
440
|
-
const log$
|
|
713
|
+
const log$13 = logger.child({ module: "local-tools-extension" });
|
|
441
714
|
const TOOLS_DIRNAME = "tools";
|
|
442
715
|
const fileState = /* @__PURE__ */ new Map();
|
|
443
716
|
let pendingLocalToolsUpdate = null;
|
|
@@ -564,7 +837,7 @@ async function reconcileLocalTools({ pi, dir }) {
|
|
|
564
837
|
action = existing ? "refreshed" : "added";
|
|
565
838
|
}
|
|
566
839
|
for (const tool of tools) {
|
|
567
|
-
pi.registerTool(withDefaultPromptSnippet(tool));
|
|
840
|
+
pi.registerTool(withSummaryStrippedBeforeValidation(withDefaultPromptSnippet(tool)));
|
|
568
841
|
summary.totalTools++;
|
|
569
842
|
}
|
|
570
843
|
if (action === "added") summary.added.push(file);
|
|
@@ -581,7 +854,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
|
|
|
581
854
|
dir
|
|
582
855
|
});
|
|
583
856
|
if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
|
|
584
|
-
log$
|
|
857
|
+
log$13.info({
|
|
585
858
|
event: "local_tools_reconcile",
|
|
586
859
|
reason,
|
|
587
860
|
total_tools: summary.totalTools,
|
|
@@ -603,7 +876,7 @@ const localToolsExtension = (pi) => {
|
|
|
603
876
|
reason: "session_start"
|
|
604
877
|
});
|
|
605
878
|
} catch (err) {
|
|
606
|
-
log$
|
|
879
|
+
log$13.error({
|
|
607
880
|
err,
|
|
608
881
|
event: "local_tools_reconcile_failed"
|
|
609
882
|
}, "local tools reconcile failed");
|
|
@@ -615,7 +888,7 @@ const localToolsExtension = (pi) => {
|
|
|
615
888
|
try {
|
|
616
889
|
current = await listToolFiles(dir);
|
|
617
890
|
} catch (err) {
|
|
618
|
-
log$
|
|
891
|
+
log$13.warn({
|
|
619
892
|
err,
|
|
620
893
|
event: "local_tools_listing_failed"
|
|
621
894
|
}, "tools/ listing failed");
|
|
@@ -637,7 +910,7 @@ const localToolsExtension = (pi) => {
|
|
|
637
910
|
reason: "auto_reload"
|
|
638
911
|
});
|
|
639
912
|
} catch (err) {
|
|
640
|
-
log$
|
|
913
|
+
log$13.error({
|
|
641
914
|
err,
|
|
642
915
|
event: "local_tools_auto_reload_failed"
|
|
643
916
|
}, "auto-reload after tools/ change failed");
|
|
@@ -691,6 +964,19 @@ const STDERR_BUFFER_BYTES = 4096;
|
|
|
691
964
|
* indistinguishable from any other transport problem. Walk the cause
|
|
692
965
|
* chain so the agent sees the real underlying error.
|
|
693
966
|
*/
|
|
967
|
+
/**
|
|
968
|
+
* True when an error from an http MCP transport (connect, listTools, or a tool
|
|
969
|
+
* call) is an authentication failure. With no authProvider configured the SDK
|
|
970
|
+
* surfaces a 401 as `StreamableHTTPError(401)`; older paths translate it to
|
|
971
|
+
* `UnauthorizedError`. A dead/expired OAuth token (the proxy can no longer
|
|
972
|
+
* mint one) shows up here on the NEXT request against a previously-connected
|
|
973
|
+
* client — not just at connect — so reconcile must re-classify such a failure
|
|
974
|
+
* as `pending_auth` instead of a generic `failed`, keeping the "waiting on
|
|
975
|
+
* auth" report consistent with `platform auth`.
|
|
976
|
+
*/
|
|
977
|
+
function isUnauthorizedError(err) {
|
|
978
|
+
return err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401;
|
|
979
|
+
}
|
|
694
980
|
function formatError(err) {
|
|
695
981
|
if (!(err instanceof Error)) return String(err);
|
|
696
982
|
const parts = [err.message];
|
|
@@ -712,12 +998,12 @@ async function connectHttp(_id, config, client) {
|
|
|
712
998
|
stderr: null
|
|
713
999
|
};
|
|
714
1000
|
} catch (err) {
|
|
715
|
-
if (err
|
|
1001
|
+
if (isUnauthorizedError(err)) return {
|
|
716
1002
|
status: "pending_auth",
|
|
717
1003
|
client,
|
|
718
1004
|
stderr: "",
|
|
719
1005
|
stderrBuffer: null,
|
|
720
|
-
cliHint: `platform auth mcp ${config.url}`
|
|
1006
|
+
cliHint: `platform auth mcp ${config.url} --service "<Product>"`
|
|
721
1007
|
};
|
|
722
1008
|
return {
|
|
723
1009
|
status: "failed",
|
|
@@ -862,7 +1148,7 @@ async function loadMcpConfig(path) {
|
|
|
862
1148
|
* Clients are keyed by JSON-stringified config and reused across
|
|
863
1149
|
* reloads — only changed configs reconnect.
|
|
864
1150
|
*/
|
|
865
|
-
const log$
|
|
1151
|
+
const log$12 = logger.child({ module: "mcp-extension" });
|
|
866
1152
|
async function closeConnected(connected) {
|
|
867
1153
|
try {
|
|
868
1154
|
await connected.client.close();
|
|
@@ -952,7 +1238,7 @@ var McpExtension = class {
|
|
|
952
1238
|
const parameters = Type.Unsafe(tool.inputSchema);
|
|
953
1239
|
const description = tool.description?.trim() ?? "";
|
|
954
1240
|
const promptSnippet = description.length > 0 ? description : `MCP tool from server "${serverId}".`;
|
|
955
|
-
pi.registerTool({
|
|
1241
|
+
pi.registerTool(withSummaryStrippedBeforeValidation({
|
|
956
1242
|
name,
|
|
957
1243
|
label: `MCP: ${serverId}/${tool.name}`,
|
|
958
1244
|
description,
|
|
@@ -986,7 +1272,7 @@ var McpExtension = class {
|
|
|
986
1272
|
};
|
|
987
1273
|
}
|
|
988
1274
|
}
|
|
989
|
-
});
|
|
1275
|
+
}));
|
|
990
1276
|
this.registeredMcpToolNames.add(name);
|
|
991
1277
|
}
|
|
992
1278
|
async reconcile({ pi, configPath, connectTimeoutMs }) {
|
|
@@ -1189,6 +1475,64 @@ var McpExtension = class {
|
|
|
1189
1475
|
try {
|
|
1190
1476
|
mcpTools = (await connected.client.listTools()).tools;
|
|
1191
1477
|
} catch (err) {
|
|
1478
|
+
if (isUnauthorizedError(err) && serverConfig.transport === "http") {
|
|
1479
|
+
await closeConnected(connected);
|
|
1480
|
+
const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
|
|
1481
|
+
if (retry.status === "pending_auth") return {
|
|
1482
|
+
id,
|
|
1483
|
+
store: {
|
|
1484
|
+
client: retry.client,
|
|
1485
|
+
configKey,
|
|
1486
|
+
status: "pending_auth",
|
|
1487
|
+
stderrBuffer: null,
|
|
1488
|
+
cliHint: retry.cliHint
|
|
1489
|
+
},
|
|
1490
|
+
serverStatus: {
|
|
1491
|
+
status: "pending_auth",
|
|
1492
|
+
stderr: "",
|
|
1493
|
+
cliHint: retry.cliHint
|
|
1494
|
+
},
|
|
1495
|
+
change: null,
|
|
1496
|
+
error: null,
|
|
1497
|
+
tools: null
|
|
1498
|
+
};
|
|
1499
|
+
if (retry.status === "failed") return {
|
|
1500
|
+
id,
|
|
1501
|
+
store: null,
|
|
1502
|
+
serverStatus: {
|
|
1503
|
+
status: "failed",
|
|
1504
|
+
error: retry.error,
|
|
1505
|
+
stderr: retry.stderr
|
|
1506
|
+
},
|
|
1507
|
+
change: null,
|
|
1508
|
+
error: {
|
|
1509
|
+
serverId: id,
|
|
1510
|
+
message: retry.error
|
|
1511
|
+
},
|
|
1512
|
+
tools: null
|
|
1513
|
+
};
|
|
1514
|
+
if (retry.status === "connected") {
|
|
1515
|
+
connected = {
|
|
1516
|
+
client: retry.client,
|
|
1517
|
+
configKey,
|
|
1518
|
+
status: "connected",
|
|
1519
|
+
stderrBuffer: retry.stderr,
|
|
1520
|
+
cliHint: null
|
|
1521
|
+
};
|
|
1522
|
+
mcpTools = (await connected.client.listTools()).tools;
|
|
1523
|
+
return {
|
|
1524
|
+
id,
|
|
1525
|
+
store: connected,
|
|
1526
|
+
serverStatus: { status: "connected" },
|
|
1527
|
+
change: action === "reused" ? "refreshed" : action,
|
|
1528
|
+
error: null,
|
|
1529
|
+
tools: {
|
|
1530
|
+
client: connected.client,
|
|
1531
|
+
list: mcpTools
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1192
1536
|
const message = err instanceof Error ? err.message : String(err);
|
|
1193
1537
|
const stderr = connected.stderrBuffer?.read() ?? "";
|
|
1194
1538
|
return {
|
|
@@ -1226,7 +1570,7 @@ var McpExtension = class {
|
|
|
1226
1570
|
});
|
|
1227
1571
|
this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
|
|
1228
1572
|
if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
|
|
1229
|
-
log$
|
|
1573
|
+
log$12.info({
|
|
1230
1574
|
event: "mcp_reconcile",
|
|
1231
1575
|
reason,
|
|
1232
1576
|
total_tools: summary.totalTools,
|
|
@@ -1249,7 +1593,7 @@ var McpExtension = class {
|
|
|
1249
1593
|
reason: "session_start"
|
|
1250
1594
|
});
|
|
1251
1595
|
} catch (err) {
|
|
1252
|
-
log$
|
|
1596
|
+
log$12.error({
|
|
1253
1597
|
err,
|
|
1254
1598
|
event: "mcp_reconcile_failed"
|
|
1255
1599
|
}, "MCP reconcile failed");
|
|
@@ -1261,7 +1605,7 @@ var McpExtension = class {
|
|
|
1261
1605
|
try {
|
|
1262
1606
|
mtime = await readConfigMtimeMs(configPath);
|
|
1263
1607
|
} catch (err) {
|
|
1264
|
-
log$
|
|
1608
|
+
log$12.warn({
|
|
1265
1609
|
err,
|
|
1266
1610
|
event: "mcp_mtime_check_failed"
|
|
1267
1611
|
}, "mtime check on mcp.config.json failed");
|
|
@@ -1275,7 +1619,7 @@ var McpExtension = class {
|
|
|
1275
1619
|
reason: "auto_reload"
|
|
1276
1620
|
});
|
|
1277
1621
|
} catch (err) {
|
|
1278
|
-
log$
|
|
1622
|
+
log$12.error({
|
|
1279
1623
|
err,
|
|
1280
1624
|
event: "mcp_auto_reload_failed"
|
|
1281
1625
|
}, "auto-reload after mcp.config.json change failed");
|
|
@@ -1638,32 +1982,396 @@ const bashDefaultTimeoutExtension = (pi) => {
|
|
|
1638
1982
|
});
|
|
1639
1983
|
};
|
|
1640
1984
|
//#endregion
|
|
1641
|
-
//#region src/
|
|
1985
|
+
//#region src/extensions/resource-pressure-warning.ts
|
|
1642
1986
|
/**
|
|
1643
|
-
*
|
|
1644
|
-
* sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
1987
|
+
* Mid-run resource-pressure warning to the agent.
|
|
1645
1988
|
*
|
|
1646
|
-
* The
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
1989
|
+
* The sandbox already detects pressure — the boot scripts cap the
|
|
1990
|
+
* user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
|
|
1991
|
+
* edges for memory and disk — but nothing told the *agent*, so a turn burned
|
|
1992
|
+
* straight to the OOM kill (or a full disk) and only learned about it from
|
|
1993
|
+
* the post-mortem notice. This extension closes that gap in-process: while a
|
|
1994
|
+
* turn is active it polls the agent cgroup and the root filesystem and, the
|
|
1995
|
+
* first time usage crosses a warn threshold, folds a system notification into
|
|
1996
|
+
* the open turn so the agent can checkpoint, shed work (constrain
|
|
1997
|
+
* parallelism, kill a background hog, clean scratch space), or request a
|
|
1998
|
+
* bigger tier BEFORE the kill.
|
|
1999
|
+
*
|
|
2000
|
+
* The notification is triggered by the two conditions that actually kill
|
|
2001
|
+
* work — memory near the cgroup hard cap, disk near full — and reports a
|
|
2002
|
+
* snapshot of all the relevant stats (memory, CPU utilization, disk) so the
|
|
2003
|
+
* agent can tell which resource is the problem and how much headroom the
|
|
2004
|
+
* others have.
|
|
2005
|
+
*
|
|
2006
|
+
* Edge-triggered, once per trigger per turn: the fired flags reset on
|
|
2007
|
+
* agent_start, so a turn that rides a threshold gets one warning per
|
|
2008
|
+
* resource, not a stream. Polling only runs while the agent is active — an
|
|
2009
|
+
* idle sandbox's resource usage is not the agent's problem and there is no
|
|
2010
|
+
* open turn to deliver into anyway.
|
|
2011
|
+
*
|
|
2012
|
+
* Best-effort throughout: any read failure (cgroup absent, controller not
|
|
2013
|
+
* delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
|
|
2014
|
+
* stat and the extension warns on what it can see — it must never break a
|
|
2015
|
+
* turn over an observability feature.
|
|
1651
2016
|
*/
|
|
1652
|
-
const
|
|
2017
|
+
const execFileAsync = promisify(execFile);
|
|
2018
|
+
const log$11 = logger.child({ module: "resource-pressure-warning" });
|
|
2019
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
2020
|
+
function envOverride(name) {
|
|
2021
|
+
for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
|
|
2022
|
+
const value = process.env[`${prefix}${name}`];
|
|
2023
|
+
if (value != null && value !== "") return value;
|
|
2024
|
+
}
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
function cgroupDir() {
|
|
2028
|
+
return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
|
|
2029
|
+
}
|
|
2030
|
+
function diskRoot() {
|
|
2031
|
+
return envOverride("DISK_ROOT") ?? "/";
|
|
2032
|
+
}
|
|
1653
2033
|
/**
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
2034
|
+
* Read a cgroup v2 scalar file. Returns a number, or null for "max"
|
|
2035
|
+
* (uncapped), an empty/absent file, or any read/parse error — an uncapped or
|
|
2036
|
+
* unreadable limit means there is nothing meaningful to warn against.
|
|
1656
2037
|
*/
|
|
1657
|
-
function
|
|
1658
|
-
if (!channelContextJson) return null;
|
|
2038
|
+
async function readScalar(file) {
|
|
1659
2039
|
try {
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
2040
|
+
const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
|
|
2041
|
+
if (raw === "" || raw === "max") return null;
|
|
2042
|
+
const n = Number(raw);
|
|
2043
|
+
return Number.isFinite(n) ? n : null;
|
|
2044
|
+
} catch (_error) {
|
|
1663
2045
|
return null;
|
|
1664
2046
|
}
|
|
1665
2047
|
}
|
|
1666
|
-
|
|
2048
|
+
/**
|
|
2049
|
+
* Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
|
|
2050
|
+
* cpu.stat) and return the counter for `key`, or null when absent.
|
|
2051
|
+
*/
|
|
2052
|
+
async function readKeyedCounter(file, key) {
|
|
2053
|
+
try {
|
|
2054
|
+
const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
|
|
2055
|
+
for (const line of raw.split("\n")) {
|
|
2056
|
+
const [k, v] = line.trim().split(/\s+/);
|
|
2057
|
+
if (k === key) {
|
|
2058
|
+
const n = Number(v);
|
|
2059
|
+
return Number.isFinite(n) ? n : null;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return null;
|
|
2063
|
+
} catch (_error) {
|
|
2064
|
+
return null;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Live memory usage as an integer percent of the hard cap, or null when
|
|
2069
|
+
* either side is unreadable/uncapped. Exported for tests.
|
|
2070
|
+
*/
|
|
2071
|
+
async function readMemUsePct() {
|
|
2072
|
+
const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
|
|
2073
|
+
if (current === null || max === null || max <= 0) return null;
|
|
2074
|
+
return {
|
|
2075
|
+
pct: Math.floor(current / max * 100),
|
|
2076
|
+
currentBytes: current,
|
|
2077
|
+
maxBytes: max
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Root filesystem used% (df -P Capacity column), or null on any failure.
|
|
2082
|
+
* Exported for tests.
|
|
2083
|
+
*/
|
|
2084
|
+
async function readDiskUsePct() {
|
|
2085
|
+
try {
|
|
2086
|
+
const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
|
|
2087
|
+
const dataRow = stdout.trim().split("\n")[1];
|
|
2088
|
+
if (dataRow == null) return null;
|
|
2089
|
+
const capacity = dataRow.trim().split(/\s+/)[4];
|
|
2090
|
+
if (capacity == null) return null;
|
|
2091
|
+
const pct = Number(capacity.replace("%", ""));
|
|
2092
|
+
return Number.isFinite(pct) ? pct : null;
|
|
2093
|
+
} catch (_error) {
|
|
2094
|
+
return null;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* CPU utilization sampler. cgroup v2 exposes cumulative CPU time
|
|
2099
|
+
* (cpu.stat usage_usec); utilization is the delta between two samples over
|
|
2100
|
+
* the wall time between them, normalized by core count. The first call after
|
|
2101
|
+
* construction has no previous sample and returns null.
|
|
2102
|
+
*/
|
|
2103
|
+
function createCpuSampler() {
|
|
2104
|
+
let prevUsageUsec = null;
|
|
2105
|
+
let prevAtMs = null;
|
|
2106
|
+
return async () => {
|
|
2107
|
+
const usage = await readKeyedCounter("cpu.stat", "usage_usec");
|
|
2108
|
+
const now = Date.now();
|
|
2109
|
+
const prev = prevUsageUsec;
|
|
2110
|
+
const prevAt = prevAtMs;
|
|
2111
|
+
prevUsageUsec = usage;
|
|
2112
|
+
prevAtMs = now;
|
|
2113
|
+
if (usage === null || prev === null || prevAt === null) return null;
|
|
2114
|
+
const wallUsec = (now - prevAt) * 1e3;
|
|
2115
|
+
if (wallUsec <= 0) return null;
|
|
2116
|
+
const cores = availableParallelism();
|
|
2117
|
+
const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
|
|
2118
|
+
return Math.max(0, Math.min(100, pct));
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
function fmtMb(bytes) {
|
|
2122
|
+
return Math.round(bytes / 1024 / 1024);
|
|
2123
|
+
}
|
|
2124
|
+
/** The model-facing warning text. Exported for tests. */
|
|
2125
|
+
function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
|
|
2126
|
+
const stats = [];
|
|
2127
|
+
if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
|
|
2128
|
+
if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
|
|
2129
|
+
if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
|
|
2130
|
+
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.`;
|
|
2131
|
+
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.";
|
|
2132
|
+
return `<system_notification>${lead} Current usage: ${stats.join(", ")}. Act now: ${remedy} If the workload genuinely needs more resources, request a bigger sandbox with \`platform compute request\`. This is an automated resource warning, not a message from the user; continue the task, adjusted.</system_notification>`;
|
|
2133
|
+
}
|
|
2134
|
+
const resourcePressureWarningExtension = (pi) => {
|
|
2135
|
+
let agentActive = false;
|
|
2136
|
+
let warnedMemThisTurn = false;
|
|
2137
|
+
let warnedDiskThisTurn = false;
|
|
2138
|
+
let timer = null;
|
|
2139
|
+
const sampleCpu = createCpuSampler();
|
|
2140
|
+
async function checkOnce() {
|
|
2141
|
+
if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
|
|
2142
|
+
const [mem, cpuPct, diskPct] = await Promise.all([
|
|
2143
|
+
readMemUsePct(),
|
|
2144
|
+
sampleCpu(),
|
|
2145
|
+
readDiskUsePct()
|
|
2146
|
+
]);
|
|
2147
|
+
let trigger = null;
|
|
2148
|
+
if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
|
|
2149
|
+
trigger = "memory";
|
|
2150
|
+
warnedMemThisTurn = true;
|
|
2151
|
+
} else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
|
|
2152
|
+
trigger = "disk";
|
|
2153
|
+
warnedDiskThisTurn = true;
|
|
2154
|
+
}
|
|
2155
|
+
if (trigger === null) return;
|
|
2156
|
+
log$11.warn({
|
|
2157
|
+
trigger,
|
|
2158
|
+
mem,
|
|
2159
|
+
cpuPct,
|
|
2160
|
+
diskPct
|
|
2161
|
+
}, "resource pressure warning delivered to agent");
|
|
2162
|
+
await pi.sendMessage({
|
|
2163
|
+
customType: "anyone-resource-pressure-warning",
|
|
2164
|
+
content: resourcePressureWarningText(trigger, {
|
|
2165
|
+
mem,
|
|
2166
|
+
cpuPct,
|
|
2167
|
+
diskPct
|
|
2168
|
+
}),
|
|
2169
|
+
display: false
|
|
2170
|
+
}, {
|
|
2171
|
+
triggerTurn: true,
|
|
2172
|
+
deliverAs: "followUp"
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
pi.on("agent_start", async () => {
|
|
2176
|
+
agentActive = true;
|
|
2177
|
+
warnedMemThisTurn = false;
|
|
2178
|
+
warnedDiskThisTurn = false;
|
|
2179
|
+
if (!timer) {
|
|
2180
|
+
timer = setInterval(() => {
|
|
2181
|
+
checkOnce().catch((err) => {
|
|
2182
|
+
log$11.error({ err }, "resource pressure check failed");
|
|
2183
|
+
});
|
|
2184
|
+
}, POLL_INTERVAL_MS);
|
|
2185
|
+
timer.unref?.();
|
|
2186
|
+
}
|
|
2187
|
+
});
|
|
2188
|
+
pi.on("agent_end", async () => {
|
|
2189
|
+
agentActive = false;
|
|
2190
|
+
if (timer) {
|
|
2191
|
+
clearInterval(timer);
|
|
2192
|
+
timer = null;
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
};
|
|
2196
|
+
//#endregion
|
|
2197
|
+
//#region src/extensions/disk-guard.ts
|
|
2198
|
+
const log$10 = logger.child({ module: "disk-guard" });
|
|
2199
|
+
/**
|
|
2200
|
+
* In-band bypass. The guard is a safety net, not a jail: when the agent knows
|
|
2201
|
+
* a flagged command is genuinely safe (writing to a different mount, a tiny
|
|
2202
|
+
* bounded download, a delete-then-clone one-liner, an emergency it accepts the
|
|
2203
|
+
* risk on) it can force the command through by appending this marker as a
|
|
2204
|
+
* trailing shell comment. Kept as a comment so it never changes what the
|
|
2205
|
+
* command does, and matched case-insensitively with flexible spacing so the
|
|
2206
|
+
* agent doesn't have to reproduce it byte-for-byte.
|
|
2207
|
+
*/
|
|
2208
|
+
const BYPASS_MARKER = /#\s*disk-guard:\s*allow\b/i;
|
|
2209
|
+
/** The exact marker text the block message tells the agent to append. */
|
|
2210
|
+
const BYPASS_HINT = "# disk-guard: allow";
|
|
2211
|
+
/**
|
|
2212
|
+
* Harness-level kill switch: set DISK_GUARD_DISABLE=1 to turn the guard off
|
|
2213
|
+
* entirely. This is the "I own my harness, let me opt out" knob — an agent
|
|
2214
|
+
* that boots its own harness can disable the guard for its whole process
|
|
2215
|
+
* without a code roll, and it's also the fleet-wide escape hatch if the
|
|
2216
|
+
* classifier ever misfires and blocks real work. The bare name is honored
|
|
2217
|
+
* first; the SKYDIVE_/ANYONE_ prefixes are accepted too for consistency with
|
|
2218
|
+
* the other env overrides. Empty/unset/"0"/"false" leave the guard on.
|
|
2219
|
+
*/
|
|
2220
|
+
function guardDisabledByEnv() {
|
|
2221
|
+
for (const name of [
|
|
2222
|
+
"DISK_GUARD_DISABLE",
|
|
2223
|
+
"SKYDIVE_DISK_GUARD_DISABLE",
|
|
2224
|
+
"ANYONE_DISK_GUARD_DISABLE"
|
|
2225
|
+
]) {
|
|
2226
|
+
const value = process.env[name];
|
|
2227
|
+
if (value != null && value !== "" && value !== "0" && value !== "false") return true;
|
|
2228
|
+
}
|
|
2229
|
+
return false;
|
|
2230
|
+
}
|
|
2231
|
+
/** True when the command carries the in-band bypass marker. */
|
|
2232
|
+
function hasBypassMarker(command) {
|
|
2233
|
+
return BYPASS_MARKER.test(command);
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Commands that reclaim space or merely inspect it. If any of these verbs
|
|
2237
|
+
* appears in the command line, we never block — otherwise the guard would trap
|
|
2238
|
+
* the agent by blocking the exact command it needs to dig out. Matched as
|
|
2239
|
+
* whole words so `remove-item` etc. don't accidentally match `rm`.
|
|
2240
|
+
*/
|
|
2241
|
+
const RECLAIM_PATTERNS = [
|
|
2242
|
+
/\brm\b/,
|
|
2243
|
+
/\brmdir\b/,
|
|
2244
|
+
/\bdf\b/,
|
|
2245
|
+
/\bdu\b/,
|
|
2246
|
+
/\bncdu\b/,
|
|
2247
|
+
/\bfind\b[^|]*\s-delete\b/,
|
|
2248
|
+
/\btruncate\b/,
|
|
2249
|
+
/\bgit\s+(gc|prune|clean|worktree\s+remove|worktree\s+prune)\b/,
|
|
2250
|
+
/\b(yarn|npm|pnpm|bun)\s+.*\b(cache\s+clean|cache\s+clear|store\s+prune)\b/,
|
|
2251
|
+
/\bcache\s+(clean|clear|prune)\b/,
|
|
2252
|
+
/\b(docker|podman)\s+.*\bprune\b/,
|
|
2253
|
+
/\bapt(-get)?\s+clean\b/,
|
|
2254
|
+
/\bjournalctl\b[^|]*--vacuum/
|
|
2255
|
+
];
|
|
2256
|
+
/**
|
|
2257
|
+
* File extensions that mean a download is actually LARGE — archives, disk
|
|
2258
|
+
* images, compiled/binary artifacts, model weights, media. A curl/wget is only
|
|
2259
|
+
* gated when it writes one of these; an API/page fetch to a `.json`/`.html`/
|
|
2260
|
+
* `.txt` file is tiny and must not be blocked. Derived from 4,144 real
|
|
2261
|
+
* commands: ~64% of `curl -o` uses were tiny fetches, only ~4% large.
|
|
2262
|
+
*/
|
|
2263
|
+
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)";
|
|
2264
|
+
/**
|
|
2265
|
+
* Commands that consume a meaningful amount of disk. Kept deliberately tight
|
|
2266
|
+
* and high-precision: validated against 4,144 real commands from the last 7
|
|
2267
|
+
* days, the earlier "writes a file" heuristic flagged 82% of everything (a
|
|
2268
|
+
* `curl -o /tmp/x.json` API call is not a disk event). This set flags ~33%,
|
|
2269
|
+
* almost all genuinely large — real installs, clones, big-archive downloads,
|
|
2270
|
+
* extractions. What was DROPPED and why:
|
|
2271
|
+
* - `git fetch` / `git pull` — incremental on an existing clone, usually tiny.
|
|
2272
|
+
* - `git checkout` — overwhelmingly `git checkout <ref> -- <file>` or a
|
|
2273
|
+
* branch switch, ~zero net growth; the rare full materialization isn't
|
|
2274
|
+
* worth the false-positive rate.
|
|
2275
|
+
* - bare `curl -o` / `wget -o` — see BIG_DOWNLOAD_EXT above.
|
|
2276
|
+
* - loose `… build` — matched `--mode=skip-build`, `oxfmt … build`, prose.
|
|
2277
|
+
* The remaining big-disk op in escher is `git clone` and `git worktree add`
|
|
2278
|
+
* (which is really a checkout), both kept.
|
|
2279
|
+
*/
|
|
2280
|
+
const SPACE_HUNGRY_PATTERNS = [
|
|
2281
|
+
/\bgit\s+clone\b/,
|
|
2282
|
+
/\bgit\s+worktree\s+add\b/,
|
|
2283
|
+
/\b(yarn|npm|pnpm|bun)\s+(install|add|ci)\b/,
|
|
2284
|
+
/\byarn\s*$/,
|
|
2285
|
+
/\byarn\s+--(?!version|help)\S/,
|
|
2286
|
+
/\bpip3?\s+install\b/,
|
|
2287
|
+
/\bapt(-get)?\s+install\b/,
|
|
2288
|
+
/\bnpm\s+pack\b/,
|
|
2289
|
+
/\bdocker\s+(build|pull)\b/,
|
|
2290
|
+
new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b`, "i"),
|
|
2291
|
+
new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b`, "i"),
|
|
2292
|
+
/\btar\s+[^\n|]*x[^\n|]*f/,
|
|
2293
|
+
/\bunzip\b/,
|
|
2294
|
+
/\bdd\b[^\n|]*\bof=/
|
|
2295
|
+
];
|
|
2296
|
+
/**
|
|
2297
|
+
* True when the command reclaims or inspects space — these are always allowed,
|
|
2298
|
+
* even on a 100%-full box, so the agent can dig itself out.
|
|
2299
|
+
*/
|
|
2300
|
+
function isReclaimCommand(command) {
|
|
2301
|
+
return RECLAIM_PATTERNS.some((re) => re.test(command));
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* True when the command is likely to consume a meaningful amount of disk.
|
|
2305
|
+
* A reclaim/inspect command is never space-hungry — the reclaim check wins so a
|
|
2306
|
+
* `git worktree remove` or a `yarn cache clean` is never mistaken for growth.
|
|
2307
|
+
*/
|
|
2308
|
+
function isSpaceHungryCommand(command) {
|
|
2309
|
+
if (isReclaimCommand(command)) return false;
|
|
2310
|
+
return SPACE_HUNGRY_PATTERNS.some((re) => re.test(command));
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* The decision, factored out and pure so it's exhaustively testable without a
|
|
2314
|
+
* real filesystem. Block only when we have a disk reading, it's at/above the
|
|
2315
|
+
* critical threshold, the command is space-hungry (and not a reclaim), and the
|
|
2316
|
+
* agent hasn't explicitly opted out with the bypass marker.
|
|
2317
|
+
*/
|
|
2318
|
+
function shouldBlockForDisk(command, diskPct) {
|
|
2319
|
+
if (diskPct === null) return false;
|
|
2320
|
+
if (diskPct < 95) return false;
|
|
2321
|
+
if (hasBypassMarker(command)) return false;
|
|
2322
|
+
return isSpaceHungryCommand(command);
|
|
2323
|
+
}
|
|
2324
|
+
/** The agent-facing explanation returned as the blocked tool result. */
|
|
2325
|
+
function diskBlockReason(command, diskPct) {
|
|
2326
|
+
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.`;
|
|
2327
|
+
}
|
|
2328
|
+
const diskGuardExtension = (pi) => {
|
|
2329
|
+
pi.on("tool_call", async (event) => {
|
|
2330
|
+
if (event.toolName !== "bash") return;
|
|
2331
|
+
if (guardDisabledByEnv()) return;
|
|
2332
|
+
const command = event.input.command;
|
|
2333
|
+
if (typeof command !== "string" || command.length === 0) return;
|
|
2334
|
+
if (hasBypassMarker(command)) return;
|
|
2335
|
+
if (!isSpaceHungryCommand(command)) return;
|
|
2336
|
+
const diskPct = await readDiskUsePct();
|
|
2337
|
+
if (!shouldBlockForDisk(command, diskPct)) return;
|
|
2338
|
+
log$10.warn({
|
|
2339
|
+
diskPct,
|
|
2340
|
+
command: command.slice(0, 200)
|
|
2341
|
+
}, "blocked space-hungry bash command on near-full disk");
|
|
2342
|
+
return {
|
|
2343
|
+
block: true,
|
|
2344
|
+
reason: diskBlockReason(command, diskPct)
|
|
2345
|
+
};
|
|
2346
|
+
});
|
|
2347
|
+
};
|
|
2348
|
+
//#endregion
|
|
2349
|
+
//#region src/channel-context-ref.ts
|
|
2350
|
+
/**
|
|
2351
|
+
* The worker injects a small reference — `{ channel, messageId, runId }` —
|
|
2352
|
+
* into the sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
|
|
2353
|
+
*
|
|
2354
|
+
* The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
|
|
2355
|
+
* `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
|
|
2356
|
+
* `@createinc/*` dependencies — importing that package would pull the whole
|
|
2357
|
+
* platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
|
|
2358
|
+
* field. So we validate the field this consumer needs locally instead.
|
|
2359
|
+
*/
|
|
2360
|
+
const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
|
|
2361
|
+
/**
|
|
2362
|
+
* Pull the current turn's message id out of the ref blob. Returns `null`
|
|
2363
|
+
* outside a turn (cron / system) or if the blob is missing/malformed.
|
|
2364
|
+
*/
|
|
2365
|
+
function extractMessageId(channelContextJson) {
|
|
2366
|
+
if (!channelContextJson) return null;
|
|
2367
|
+
try {
|
|
2368
|
+
const parsed = channelContextRefSchema.safeParse(JSON.parse(channelContextJson));
|
|
2369
|
+
return parsed.success ? parsed.data.messageId : null;
|
|
2370
|
+
} catch {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
//#endregion
|
|
1667
2375
|
//#region src/api-url.ts
|
|
1668
2376
|
/**
|
|
1669
2377
|
* Resolve the Skydive API base URL from the sandbox env. Newly-provisioned
|
|
@@ -1679,20 +2387,84 @@ function apiBaseUrl() {
|
|
|
1679
2387
|
}
|
|
1680
2388
|
//#endregion
|
|
1681
2389
|
//#region src/extensions/platform.ts
|
|
2390
|
+
/**
|
|
2391
|
+
* Platform extension — bridges the agent harness to the Skydive platform daemon.
|
|
2392
|
+
*
|
|
2393
|
+
* Responsibilities:
|
|
2394
|
+
* - Heartbeat: periodic POST to the API so the sandbox manager knows the
|
|
2395
|
+
* agent is alive. Throttled to once per minute, triggered by tool events.
|
|
2396
|
+
* - Session tracking: registers the session with the daemon on start,
|
|
2397
|
+
* streams tool_call / tool_result events so the daemon can track which
|
|
2398
|
+
* session is actively executing, and signals session end on agent_end.
|
|
2399
|
+
* - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
|
|
2400
|
+
* messageId) to the daemon so file writes can be attributed to the
|
|
2401
|
+
* correct conversation.
|
|
2402
|
+
*
|
|
2403
|
+
* All daemon POSTs are fire-and-forget — failures are logged but never
|
|
2404
|
+
* block the agent. The daemon may not be running (e.g. local dev without
|
|
2405
|
+
* a sandbox), and that's fine.
|
|
2406
|
+
*/
|
|
1682
2407
|
const HEARTBEAT_THROTTLE_MS = 6e4;
|
|
1683
2408
|
const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1684
2409
|
const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
|
|
1685
2410
|
const DAEMON_URL = "http://localhost:38994";
|
|
1686
|
-
const log$
|
|
2411
|
+
const log$9 = logger.child({ module: "platform-ext" });
|
|
1687
2412
|
function sandboxClient() {
|
|
1688
2413
|
const apiUrl = apiBaseUrl();
|
|
1689
2414
|
if (!apiUrl) return null;
|
|
1690
2415
|
return hc(`${apiUrl}/api/v1/sandbox`);
|
|
1691
2416
|
}
|
|
1692
2417
|
/**
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
2418
|
+
* Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
|
|
2419
|
+
* feature-flags half of the ANY-5184 pool 403 wave.)
|
|
2420
|
+
*
|
|
2421
|
+
* `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
|
|
2422
|
+
* from a pool box can only 403 — a guaranteed-failing GET every 60s for the
|
|
2423
|
+
* life of the pool phase. The discriminator is the sandbox token's `type`
|
|
2424
|
+
* claim, read UNVERIFIED (this box never holds the signing secret): not an
|
|
2425
|
+
* authorization decision, only "should I bother calling?", and the api still
|
|
2426
|
+
* authorizes every request.
|
|
2427
|
+
*
|
|
2428
|
+
* Read per call from the daemon's persisted env file, NOT process.env:
|
|
2429
|
+
* claiming a pool box rebinds the token in place (the daemon rewrites this
|
|
2430
|
+
* file) while the harness's process.env keeps the boot snapshot, so a
|
|
2431
|
+
* process-env gate would leave a claimed box permanently skipping — trading a
|
|
2432
|
+
* wasted request for silently frozen flags, which is strictly worse. "Cannot
|
|
2433
|
+
* tell" (no file, no token, unparseable payload) reports false so the poll
|
|
2434
|
+
* proceeds.
|
|
2435
|
+
*/
|
|
2436
|
+
const daemonEnvIdentitySchema = z.object({
|
|
2437
|
+
ANYONE_SANDBOX_TOKEN: z.string().optional(),
|
|
2438
|
+
SKYDIVE_SANDBOX_TOKEN: z.string().optional()
|
|
2439
|
+
}).passthrough();
|
|
2440
|
+
const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
|
|
2441
|
+
async function isPoolIdentity() {
|
|
2442
|
+
try {
|
|
2443
|
+
const override = process.env.ANYONE_DAEMON_ENV_CACHE;
|
|
2444
|
+
const candidates = override ? [override] : ["/run/anyone-system/daemon-env.json", "/tmp/.anyone/daemon-env.json"];
|
|
2445
|
+
let raw = null;
|
|
2446
|
+
for (const file of candidates) {
|
|
2447
|
+
raw = await readFile(file, "utf8").catch(() => null);
|
|
2448
|
+
if (raw !== null) break;
|
|
2449
|
+
}
|
|
2450
|
+
if (raw === null) return false;
|
|
2451
|
+
const env = daemonEnvIdentitySchema.safeParse(JSON.parse(raw));
|
|
2452
|
+
if (!env.success) return false;
|
|
2453
|
+
const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
|
|
2454
|
+
if (typeof token !== "string" || token === "") return false;
|
|
2455
|
+
const payload = token.split(".")[1];
|
|
2456
|
+
if (!payload) return false;
|
|
2457
|
+
const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
2458
|
+
return claims.success && claims.data.type === "onboarding-pool";
|
|
2459
|
+
} catch (_err) {
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
|
|
2465
|
+
* — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
|
|
2466
|
+
* null when indeterminate (no api url, the request failed, or the box is an
|
|
2467
|
+
* unclaimed pool sandbox whose token the route would 403) so the shared
|
|
1696
2468
|
* poller keeps the last-known values rather than flipping on a transient error.
|
|
1697
2469
|
* This is the single fetch behind `feature-flags-poll.ts`; extensions read the
|
|
1698
2470
|
* polled values there instead of issuing their own GET.
|
|
@@ -1700,22 +2472,19 @@ function sandboxClient() {
|
|
|
1700
2472
|
async function fetchHarnessFlags() {
|
|
1701
2473
|
const client = sandboxClient();
|
|
1702
2474
|
if (!client) return null;
|
|
2475
|
+
if (await isPoolIdentity()) return null;
|
|
1703
2476
|
try {
|
|
1704
2477
|
const res = await client["feature-flags"].$get();
|
|
1705
2478
|
if (!res.ok) {
|
|
1706
|
-
log$
|
|
2479
|
+
log$9.debug({
|
|
1707
2480
|
status: res.status,
|
|
1708
2481
|
event: "feature_flags_fetch_failed"
|
|
1709
2482
|
}, "feature-flags fetch failed");
|
|
1710
2483
|
return null;
|
|
1711
2484
|
}
|
|
1712
|
-
|
|
1713
|
-
return {
|
|
1714
|
-
contextManagement: body.contextManagement ?? null,
|
|
1715
|
-
subagent: body.subagent ?? null
|
|
1716
|
-
};
|
|
2485
|
+
return { contextManagement: (await res.json()).contextManagement ?? null };
|
|
1717
2486
|
} catch (err) {
|
|
1718
|
-
log$
|
|
2487
|
+
log$9.debug({
|
|
1719
2488
|
err,
|
|
1720
2489
|
event: "feature_flags_fetch_error"
|
|
1721
2490
|
}, "feature-flags request errored");
|
|
@@ -1726,7 +2495,7 @@ function postHeartbeat({ messageId }) {
|
|
|
1726
2495
|
const client = sandboxClient();
|
|
1727
2496
|
if (!client) return;
|
|
1728
2497
|
client.heartbeat.$post({ json: { messageId } }).catch((err) => {
|
|
1729
|
-
log$
|
|
2498
|
+
log$9.debug({
|
|
1730
2499
|
err,
|
|
1731
2500
|
event: "heartbeat_failed"
|
|
1732
2501
|
}, "heartbeat failed");
|
|
@@ -1738,7 +2507,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1738
2507
|
try {
|
|
1739
2508
|
const res = await client["message-conversation"].$get({ query: { messageId } });
|
|
1740
2509
|
if (!res.ok) {
|
|
1741
|
-
log$
|
|
2510
|
+
log$9.warn({
|
|
1742
2511
|
status: res.status,
|
|
1743
2512
|
messageId,
|
|
1744
2513
|
event: "resolve_conversation_failed"
|
|
@@ -1747,7 +2516,7 @@ async function resolveConversationFromApi(messageId) {
|
|
|
1747
2516
|
}
|
|
1748
2517
|
return (await res.json()).conversationId ?? null;
|
|
1749
2518
|
} catch (err) {
|
|
1750
|
-
log$
|
|
2519
|
+
log$9.warn({
|
|
1751
2520
|
err,
|
|
1752
2521
|
messageId,
|
|
1753
2522
|
event: "resolve_conversation_error"
|
|
@@ -1764,6 +2533,71 @@ async function postBackgroundTaskDone({ messageId, content }) {
|
|
|
1764
2533
|
} });
|
|
1765
2534
|
if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
|
|
1766
2535
|
}
|
|
2536
|
+
async function putBackgroundTaskJournalSpec({ messageId, spec }) {
|
|
2537
|
+
const client = sandboxClient();
|
|
2538
|
+
if (!client) return;
|
|
2539
|
+
try {
|
|
2540
|
+
const res = await client["bg-task-journal"].$put({ json: {
|
|
2541
|
+
messageId,
|
|
2542
|
+
spec
|
|
2543
|
+
} });
|
|
2544
|
+
if (!res.ok) log$9.warn({
|
|
2545
|
+
status: res.status,
|
|
2546
|
+
taskId: spec.id,
|
|
2547
|
+
event: "bg_journal_put_failed"
|
|
2548
|
+
}, "bg-task journal PUT failed");
|
|
2549
|
+
} catch (err) {
|
|
2550
|
+
log$9.warn({
|
|
2551
|
+
err,
|
|
2552
|
+
taskId: spec.id,
|
|
2553
|
+
event: "bg_journal_put_failed"
|
|
2554
|
+
}, "bg-task journal PUT threw");
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
async function deleteBackgroundTaskJournalSpec({ messageId, taskId }) {
|
|
2558
|
+
const client = sandboxClient();
|
|
2559
|
+
if (!client) return;
|
|
2560
|
+
try {
|
|
2561
|
+
await client["bg-task-journal"].$delete({ json: {
|
|
2562
|
+
messageId,
|
|
2563
|
+
taskId
|
|
2564
|
+
} });
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
log$9.debug({
|
|
2567
|
+
err,
|
|
2568
|
+
taskId,
|
|
2569
|
+
event: "bg_journal_delete_failed"
|
|
2570
|
+
}, "bg-task journal DELETE failed");
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
async function listBackgroundTaskJournalSpecs({ messageId }) {
|
|
2574
|
+
const client = sandboxClient();
|
|
2575
|
+
if (!client) return [];
|
|
2576
|
+
try {
|
|
2577
|
+
const res = await client["bg-task-journal"].$get({ query: { messageId } });
|
|
2578
|
+
if (!res.ok) return [];
|
|
2579
|
+
return (await res.json()).specs ?? [];
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
log$9.debug({
|
|
2582
|
+
err,
|
|
2583
|
+
event: "bg_journal_list_failed"
|
|
2584
|
+
}, "bg-task journal GET failed");
|
|
2585
|
+
return [];
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
function postBackgroundTasksSnapshot({ messageId, tasks }) {
|
|
2589
|
+
const client = sandboxClient();
|
|
2590
|
+
if (!client || !messageId) return;
|
|
2591
|
+
client["bg-tasks"].$post({ json: {
|
|
2592
|
+
messageId,
|
|
2593
|
+
tasks
|
|
2594
|
+
} }).catch((err) => {
|
|
2595
|
+
log$9.debug({
|
|
2596
|
+
err,
|
|
2597
|
+
event: "bg_tasks_snapshot_failed"
|
|
2598
|
+
}, "bg-tasks snapshot publish failed");
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
1767
2601
|
async function postSubagentSpawn({ messageId, tasks }) {
|
|
1768
2602
|
const client = sandboxClient();
|
|
1769
2603
|
if (!client) throw new Error("no api url for subagent-spawn");
|
|
@@ -1771,8 +2605,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
|
|
|
1771
2605
|
messageId,
|
|
1772
2606
|
tasks
|
|
1773
2607
|
} });
|
|
1774
|
-
if (!res.ok)
|
|
1775
|
-
|
|
2608
|
+
if (!res.ok) {
|
|
2609
|
+
let detail = "";
|
|
2610
|
+
try {
|
|
2611
|
+
const errBody = await res.json();
|
|
2612
|
+
if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
|
|
2613
|
+
} catch {}
|
|
2614
|
+
throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
|
|
2615
|
+
}
|
|
2616
|
+
const body = await res.json();
|
|
2617
|
+
return {
|
|
2618
|
+
taskIds: body.taskIds,
|
|
2619
|
+
tasks: body.tasks ?? []
|
|
2620
|
+
};
|
|
1776
2621
|
}
|
|
1777
2622
|
function createHeartbeatThrottle({ messageId }) {
|
|
1778
2623
|
let lastAt = 0;
|
|
@@ -1821,7 +2666,7 @@ function createToolHeartbeat({ messageId }) {
|
|
|
1821
2666
|
}
|
|
1822
2667
|
heartbeatCount++;
|
|
1823
2668
|
if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
|
|
1824
|
-
log$
|
|
2669
|
+
log$9.warn({
|
|
1825
2670
|
heartbeatCount,
|
|
1826
2671
|
activeToolCalls: [...activeToolCalls]
|
|
1827
2672
|
}, "tool heartbeat max reached, stopping");
|
|
@@ -1852,7 +2697,7 @@ function postToDaemon(path, body) {
|
|
|
1852
2697
|
headers: { "content-type": "application/json" },
|
|
1853
2698
|
body: JSON.stringify(body)
|
|
1854
2699
|
}).catch((err) => {
|
|
1855
|
-
log$
|
|
2700
|
+
log$9.debug({
|
|
1856
2701
|
err,
|
|
1857
2702
|
path,
|
|
1858
2703
|
event: "daemon_post_failed"
|
|
@@ -1861,7 +2706,7 @@ function postToDaemon(path, body) {
|
|
|
1861
2706
|
}
|
|
1862
2707
|
function createPlatformExtensions({ sessionId, channelContext }) {
|
|
1863
2708
|
return (pi) => {
|
|
1864
|
-
log$
|
|
2709
|
+
log$9.info({
|
|
1865
2710
|
sessionId,
|
|
1866
2711
|
hasChannelContext: Boolean(channelContext)
|
|
1867
2712
|
}, "platform extension initialized");
|
|
@@ -1910,7 +2755,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1910
2755
|
});
|
|
1911
2756
|
});
|
|
1912
2757
|
pi.on("agent_end", () => {
|
|
1913
|
-
log$
|
|
2758
|
+
log$9.info({ sessionId }, "session ending");
|
|
1914
2759
|
postToDaemon("/session/end", { sessionId });
|
|
1915
2760
|
});
|
|
1916
2761
|
};
|
|
@@ -1921,37 +2766,39 @@ function createPlatformExtensions({ sessionId, channelContext }) {
|
|
|
1921
2766
|
* Shared harness feature-flag poll.
|
|
1922
2767
|
*
|
|
1923
2768
|
* The api exposes one `/feature-flags` GET that returns every harness flag in a
|
|
1924
|
-
* single response (`{ contextManagement,
|
|
2769
|
+
* single response (`{ contextManagement, commandFlags }` — see
|
|
1925
2770
|
* apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
|
|
1926
2771
|
* extension issuing its own GET — and, worse, a *blocking* GET on the
|
|
1927
2772
|
* pre-first-token `session_start` path — a single background poller fetches
|
|
1928
2773
|
* that response once per interval and fans the values out to every subscriber.
|
|
1929
2774
|
*
|
|
1930
|
-
* Why one poller:
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
* every session, flag on or off. Reading the last-polled value instead keeps
|
|
1935
|
-
* the hot path allocation-only. A cold cache reads as `null` (fail-open to
|
|
1936
|
-
* unregistered); a newly-flipped flag takes effect on the next poll, matching
|
|
1937
|
-
* how context-management already treats its flag.
|
|
2775
|
+
* Why one poller: context-management consumes the `contextManagement` flag
|
|
2776
|
+
* without a blocking GET on the pre-first-token `session_start` path. Reading
|
|
2777
|
+
* the last-polled value keeps the hot path allocation-only; a cold cache reads
|
|
2778
|
+
* as `null` and a newly-flipped flag takes effect on the next poll.
|
|
1938
2779
|
*
|
|
1939
2780
|
* The poll is fire-and-forget and self-unref'd — it never keeps the process
|
|
1940
2781
|
* alive and an indeterminate result (no api url / transient failure) leaves the
|
|
1941
2782
|
* last-known values untouched so a blip can't silently flip behavior.
|
|
1942
2783
|
*/
|
|
1943
|
-
const log$
|
|
2784
|
+
const log$8 = logger.child({ module: "feature-flags-poll" });
|
|
1944
2785
|
const FLAG_POLL_INTERVAL_MS = 6e4;
|
|
1945
2786
|
let contextManagement = null;
|
|
1946
|
-
|
|
1947
|
-
const subscribers = {
|
|
1948
|
-
contextManagement: /* @__PURE__ */ new Set(),
|
|
1949
|
-
subagent: /* @__PURE__ */ new Set()
|
|
1950
|
-
};
|
|
2787
|
+
const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
|
|
1951
2788
|
let pollerStarted = false;
|
|
2789
|
+
let firstPollSettled = false;
|
|
2790
|
+
let resolveFirstPoll = null;
|
|
2791
|
+
new Promise((resolve) => {
|
|
2792
|
+
resolveFirstPoll = resolve;
|
|
2793
|
+
});
|
|
2794
|
+
function markFirstPollSettled() {
|
|
2795
|
+
if (firstPollSettled) return;
|
|
2796
|
+
firstPollSettled = true;
|
|
2797
|
+
resolveFirstPoll?.();
|
|
2798
|
+
}
|
|
1952
2799
|
/** Last-polled value of a flag, or `null` if not yet resolved. */
|
|
1953
|
-
function getPolledFlag(
|
|
1954
|
-
return
|
|
2800
|
+
function getPolledFlag(_name) {
|
|
2801
|
+
return contextManagement;
|
|
1955
2802
|
}
|
|
1956
2803
|
/**
|
|
1957
2804
|
* Subscribe to changes of a flag. The callback fires only on a *transition*
|
|
@@ -1964,23 +2811,25 @@ function onFlagChange(name, cb) {
|
|
|
1964
2811
|
}
|
|
1965
2812
|
function apply(name, next) {
|
|
1966
2813
|
if (next === null) return;
|
|
1967
|
-
const prev =
|
|
1968
|
-
|
|
1969
|
-
else subagent = next;
|
|
2814
|
+
const prev = contextManagement;
|
|
2815
|
+
contextManagement = next;
|
|
1970
2816
|
if (next !== prev) for (const cb of subscribers[name]) try {
|
|
1971
2817
|
cb(next);
|
|
1972
2818
|
} catch (err) {
|
|
1973
|
-
log$
|
|
2819
|
+
log$8.warn({
|
|
1974
2820
|
err,
|
|
1975
2821
|
flag: name
|
|
1976
2822
|
}, "flag subscriber threw");
|
|
1977
2823
|
}
|
|
1978
2824
|
}
|
|
1979
2825
|
async function pollOnce() {
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2826
|
+
try {
|
|
2827
|
+
const flags = await fetchHarnessFlags();
|
|
2828
|
+
if (!flags) return;
|
|
2829
|
+
apply("contextManagement", flags.contextManagement ?? null);
|
|
2830
|
+
} catch (err) {
|
|
2831
|
+
log$8.debug({ err }, "feature-flag poll threw");
|
|
2832
|
+
}
|
|
1984
2833
|
}
|
|
1985
2834
|
/**
|
|
1986
2835
|
* Start the shared background poll (idempotent). No-op when there's no
|
|
@@ -1991,7 +2840,7 @@ async function pollOnce() {
|
|
|
1991
2840
|
function startFeatureFlagPoller() {
|
|
1992
2841
|
if (pollerStarted || !hasFlagSource()) return;
|
|
1993
2842
|
pollerStarted = true;
|
|
1994
|
-
pollOnce();
|
|
2843
|
+
pollOnce().finally(markFirstPollSettled);
|
|
1995
2844
|
setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
|
|
1996
2845
|
}
|
|
1997
2846
|
//#endregion
|
|
@@ -2086,7 +2935,7 @@ function transformContextMessages(messages, config, now) {
|
|
|
2086
2935
|
}
|
|
2087
2936
|
//#endregion
|
|
2088
2937
|
//#region src/extensions/context-management.ts
|
|
2089
|
-
const log$
|
|
2938
|
+
const log$7 = logger.child({ module: "context-management-extension" });
|
|
2090
2939
|
function isAnthropicMessagesPayload(payload) {
|
|
2091
2940
|
if (typeof payload !== "object" || payload === null) return false;
|
|
2092
2941
|
const candidate = payload;
|
|
@@ -2151,13 +3000,13 @@ function createContextManagementExtension() {
|
|
|
2151
3000
|
setContextManagementFlagOverride(getPolledFlag("contextManagement"));
|
|
2152
3001
|
onFlagChange("contextManagement", (enabled) => {
|
|
2153
3002
|
setContextManagementFlagOverride(enabled);
|
|
2154
|
-
log$
|
|
3003
|
+
log$7.info({
|
|
2155
3004
|
event: "context_management_flag_update",
|
|
2156
3005
|
enabled
|
|
2157
3006
|
}, "context-management flag updated from platform");
|
|
2158
3007
|
});
|
|
2159
3008
|
startFeatureFlagPoller();
|
|
2160
|
-
log$
|
|
3009
|
+
log$7.info({
|
|
2161
3010
|
event: "context_management_registered",
|
|
2162
3011
|
enabled: initial.enabled,
|
|
2163
3012
|
flagSource: hasFlagSource(),
|
|
@@ -2169,13 +3018,13 @@ function createContextManagementExtension() {
|
|
|
2169
3018
|
const { messages } = event;
|
|
2170
3019
|
try {
|
|
2171
3020
|
const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
|
|
2172
|
-
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$
|
|
3021
|
+
if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$7.info({
|
|
2173
3022
|
event: "context_management_applied",
|
|
2174
3023
|
...result.stats
|
|
2175
3024
|
}, "trimmed/cleared tool output before LLM call");
|
|
2176
3025
|
return { messages: result.messages };
|
|
2177
3026
|
} catch (err) {
|
|
2178
|
-
log$
|
|
3027
|
+
log$7.error({
|
|
2179
3028
|
err,
|
|
2180
3029
|
event: "context_management_transform_failed"
|
|
2181
3030
|
}, "context transform failed; passing messages through unchanged");
|
|
@@ -2187,7 +3036,7 @@ function createContextManagementExtension() {
|
|
|
2187
3036
|
}
|
|
2188
3037
|
//#endregion
|
|
2189
3038
|
//#region src/extensions/current-time.ts
|
|
2190
|
-
const log$
|
|
3039
|
+
const log$6 = logger.child({ module: "current-time-extension" });
|
|
2191
3040
|
const PI_DATE_LINE = /^Current date:.*$/m;
|
|
2192
3041
|
function formatCurrentTimeLine(now) {
|
|
2193
3042
|
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 +3049,7 @@ const currentTimeExtension = (pi) => {
|
|
|
2200
3049
|
const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
|
|
2201
3050
|
const base = event.systemPrompt;
|
|
2202
3051
|
if (PI_DATE_LINE.test(base)) {
|
|
2203
|
-
log$
|
|
3052
|
+
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
3053
|
return { systemPrompt: base.replace(PI_DATE_LINE, line) };
|
|
2205
3054
|
}
|
|
2206
3055
|
return { systemPrompt: `${base}\n${line}` };
|
|
@@ -2209,37 +3058,35 @@ const currentTimeExtension = (pi) => {
|
|
|
2209
3058
|
//#endregion
|
|
2210
3059
|
//#region src/memory.ts
|
|
2211
3060
|
/**
|
|
2212
|
-
* In-harness memory
|
|
3061
|
+
* In-harness memory readers.
|
|
2213
3062
|
*
|
|
2214
|
-
* The agent has an agent-level file-based memory at `<cwd>/.memory/`,
|
|
2215
|
-
*
|
|
3063
|
+
* The agent has an agent-level file-based memory at `<cwd>/.memory/`, split
|
|
3064
|
+
* into two halves that are surfaced differently:
|
|
2216
3065
|
*
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
3066
|
+
* 1. Shared knowledge (projects, lessons, external systems) — indexed by a
|
|
3067
|
+
* single hand-maintained `<cwd>/.memory/MEMORY.md` that the *agent* writes
|
|
3068
|
+
* and curates, Claude-Code style: one line per fact pointing at the file
|
|
3069
|
+
* that holds it. `readMemoryIndexFile` just reads that file; the agent owns
|
|
3070
|
+
* its contents. This is the whole index for the shared half — there is no
|
|
3071
|
+
* derived walk and no per-directory `MEMORY.md`.
|
|
2221
3072
|
*
|
|
2222
|
-
*
|
|
2223
|
-
*
|
|
2224
|
-
*
|
|
3073
|
+
* 2. Per-person memory — `.memory/users/<id>-<name>/<topic>.md`. This half is
|
|
3074
|
+
* *derived*, not hand-maintained, because it has to be filtered to the one
|
|
3075
|
+
* person on the current turn (a single hand-written index couldn't be
|
|
3076
|
+
* scoped per-user without leaking one person's notes into another's
|
|
3077
|
+
* conversation). `buildMemoryIndex` walks a single user's directory, reads
|
|
3078
|
+
* only the frontmatter of each `.md` (open fd → read first ~4KB → close, in
|
|
3079
|
+
* parallel), and renders an index. Each `.md`'s frontmatter carries `name`
|
|
3080
|
+
* and `description`; bodies are never read — the agent loads a specific
|
|
3081
|
+
* memory's body on demand via the `read` tool.
|
|
2225
3082
|
*
|
|
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.
|
|
3083
|
+
* Mtime cache (for the derived per-user half) keyed by cwd — within the
|
|
3084
|
+
* lifetime of a sandbox the cwd is fixed, so this is effectively a single-entry
|
|
3085
|
+
* cache. It invalidates when any `.md` under `users/` is added/modified/
|
|
3086
|
+
* deleted; turns where memory didn't change reuse the cached entries.
|
|
2233
3087
|
*
|
|
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.
|
|
3088
|
+
* Frontmatter is parsed as YAML (`yaml` package) and validated with a zod
|
|
3089
|
+
* schema — files that don't match the shape are dropped from the index.
|
|
2243
3090
|
*/
|
|
2244
3091
|
const FRONTMATTER_READ_BYTES = 4096;
|
|
2245
3092
|
const FrontmatterSchema = z.object({
|
|
@@ -2247,33 +3094,118 @@ const FrontmatterSchema = z.object({
|
|
|
2247
3094
|
description: z.string().min(1)
|
|
2248
3095
|
}).passthrough();
|
|
2249
3096
|
const MEMORY_DIRNAME = ".memory";
|
|
2250
|
-
const
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
3097
|
+
const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
3098
|
+
const USERS_DIRNAME = "users";
|
|
3099
|
+
/**
|
|
3100
|
+
* The shared-knowledge type dirs from the old frontmatter-indexed layout, used
|
|
3101
|
+
* only to seed a `MEMORY.md` for agents created before it existed (see
|
|
3102
|
+
* `seedMemoryIndexFile`). `users/` is deliberately excluded — per-person memory
|
|
3103
|
+
* stays derived and never lands in the shared, un-scoped `MEMORY.md`.
|
|
3104
|
+
*/
|
|
3105
|
+
const LEGACY_SHARED_TYPES = [
|
|
3106
|
+
{
|
|
3107
|
+
dir: "projects",
|
|
3108
|
+
label: "Projects"
|
|
3109
|
+
},
|
|
3110
|
+
{
|
|
3111
|
+
dir: "feedback",
|
|
3112
|
+
label: "Feedback"
|
|
3113
|
+
},
|
|
3114
|
+
{
|
|
3115
|
+
dir: "reference",
|
|
3116
|
+
label: "Reference"
|
|
3117
|
+
}
|
|
2255
3118
|
];
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
3119
|
+
/**
|
|
3120
|
+
* Soft budget for an injected index block. The index is read and injected into
|
|
3121
|
+
* the system prompt on every turn, so every entry costs context for the rest of
|
|
3122
|
+
* the conversation. Past this size we nudge the agent to consolidate and prune
|
|
3123
|
+
* rather than keep appending. Not a hard cap — nothing is truncated.
|
|
3124
|
+
*/
|
|
3125
|
+
const MEMORY_INDEX_SOFT_BUDGET_CHARS = 2e4;
|
|
3126
|
+
/**
|
|
3127
|
+
* Hard cap on the injected index — double the soft budget. The soft budget only
|
|
3128
|
+
* warns; this actually bounds what we inject so a runaway index can't consume
|
|
3129
|
+
* unbounded context on every turn. Past this, the index is truncated (on a line
|
|
3130
|
+
* boundary) before injection. It's a backstop, not a normal operating point.
|
|
3131
|
+
*/
|
|
3132
|
+
const MEMORY_INDEX_HARD_BUDGET_CHARS = MEMORY_INDEX_SOFT_BUDGET_CHARS * 2;
|
|
3133
|
+
/**
|
|
3134
|
+
* Cheap size summary of a rendered index block, used to surface how much of the
|
|
3135
|
+
* every-turn context budget the index is spending so the agent keeps it lean.
|
|
3136
|
+
* Counts pointer/file lines — both the derived `` - `path` — desc`` form and
|
|
3137
|
+
* the hand-maintained `- [Title](path) — hook` form — not the group headers.
|
|
3138
|
+
*/
|
|
3139
|
+
function summarizeIndex(index) {
|
|
3140
|
+
const entryCount = index.split("\n").filter((line) => /^\s*- (?:`|\[)/.test(line)).length;
|
|
3141
|
+
const charCount = index.length;
|
|
3142
|
+
return {
|
|
3143
|
+
entryCount,
|
|
3144
|
+
charCount,
|
|
3145
|
+
overBudget: charCount > MEMORY_INDEX_SOFT_BUDGET_CHARS
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* One-line size note for an index block header, e.g. `12 entries, 3187 chars`.
|
|
3150
|
+
*/
|
|
3151
|
+
function indexSizeNote(index) {
|
|
3152
|
+
const { entryCount, charCount } = summarizeIndex(index);
|
|
3153
|
+
return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}, ${charCount} chars`;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* An explicit warning to surface to the agent when an index has grown past its
|
|
3157
|
+
* budget, or `null` when it's within budget. Extensions render this prominently
|
|
3158
|
+
* above the index so the agent prunes before it keeps appending.
|
|
3159
|
+
*/
|
|
3160
|
+
function indexBudgetWarning(index) {
|
|
3161
|
+
const { charCount, overBudget } = summarizeIndex(index);
|
|
3162
|
+
if (!overBudget) return null;
|
|
3163
|
+
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.`;
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Enforce the hard cap on an index before injection. Under the cap the index is
|
|
3167
|
+
* returned unchanged; over it, the index is truncated on a line boundary and a
|
|
3168
|
+
* notice is appended naming the true size so the agent knows entries are hidden
|
|
3169
|
+
* and must be pruned. This is the actual bound on injected context — callers
|
|
3170
|
+
* still report the true size via {@link indexSizeNote} so nothing is masked.
|
|
3171
|
+
*/
|
|
3172
|
+
function enforceMemoryIndexHardBudget(index) {
|
|
3173
|
+
if (index.length <= 4e4) return index;
|
|
3174
|
+
const clipped = index.slice(0, MEMORY_INDEX_HARD_BUDGET_CHARS);
|
|
3175
|
+
const lastNewline = clipped.lastIndexOf("\n");
|
|
3176
|
+
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.`;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* Read the agent's hand-maintained shared index at `.memory/MEMORY.md`.
|
|
3180
|
+
* Returns the trimmed contents, or `null` when the file is absent or empty —
|
|
3181
|
+
* the agent owns this file, so we surface exactly what it wrote.
|
|
3182
|
+
*/
|
|
3183
|
+
async function readMemoryIndexFile({ cwd }) {
|
|
3184
|
+
const path = join(cwd, MEMORY_DIRNAME, MEMORY_INDEX_FILENAME);
|
|
3185
|
+
try {
|
|
3186
|
+
const trimmed = (await readFile(path, "utf-8")).trim();
|
|
3187
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3188
|
+
} catch {
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
2263
3192
|
const cache = /* @__PURE__ */ new Map();
|
|
2264
3193
|
/**
|
|
3194
|
+
* Build the derived per-person index for a single user.
|
|
3195
|
+
*
|
|
2265
3196
|
* Returns:
|
|
2266
|
-
* - `null` if `.memory/` doesn't exist
|
|
2267
|
-
* - `""` if
|
|
3197
|
+
* - `null` if `.memory/users/` doesn't exist
|
|
3198
|
+
* - `""` if it exists but this user has no memory
|
|
2268
3199
|
* - rendered markdown body (no surrounding header — caller wraps)
|
|
2269
3200
|
*
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
3201
|
+
* Scoping by id keeps one person's memory from bleeding into another's
|
|
3202
|
+
* conversation. The mtime-keyed cache stores the raw walked entries (the cost
|
|
3203
|
+
* is the FS walk); filtering by user is cheap and runs per call, so two turns
|
|
3204
|
+
* with different users on the same cwd render correctly from one cached walk.
|
|
2273
3205
|
*/
|
|
2274
|
-
async function buildMemoryIndex({ cwd,
|
|
2275
|
-
const
|
|
2276
|
-
const maxMtimeMs = await maxMtimeAcrossDir(
|
|
3206
|
+
async function buildMemoryIndex({ cwd, userId }) {
|
|
3207
|
+
const usersDirAbs = join(cwd, MEMORY_DIRNAME, USERS_DIRNAME);
|
|
3208
|
+
const maxMtimeMs = await maxMtimeAcrossDir(usersDirAbs);
|
|
2277
3209
|
if (maxMtimeMs === null) {
|
|
2278
3210
|
cache.delete(cwd);
|
|
2279
3211
|
return null;
|
|
@@ -2281,13 +3213,13 @@ async function buildMemoryIndex({ cwd, scope }) {
|
|
|
2281
3213
|
let cached = cache.get(cwd);
|
|
2282
3214
|
if (!cached || cached.builtAtMs < maxMtimeMs) {
|
|
2283
3215
|
cached = {
|
|
2284
|
-
entries: await
|
|
3216
|
+
entries: await collectUserEntries(usersDirAbs, cwd),
|
|
2285
3217
|
builtAtMs: Date.now()
|
|
2286
3218
|
};
|
|
2287
3219
|
cache.set(cwd, cached);
|
|
2288
3220
|
}
|
|
2289
|
-
const visible = cached.entries.filter((entry) =>
|
|
2290
|
-
return visible.length === 0 ? "" :
|
|
3221
|
+
const visible = cached.entries.filter((entry) => entry.subject.startsWith(userId));
|
|
3222
|
+
return visible.length === 0 ? "" : renderUserIndex(visible);
|
|
2291
3223
|
}
|
|
2292
3224
|
async function maxMtimeAcrossDir(dir) {
|
|
2293
3225
|
let dirStat;
|
|
@@ -2335,45 +3267,22 @@ async function listSubdirs(dir) {
|
|
|
2335
3267
|
}
|
|
2336
3268
|
return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
|
|
2337
3269
|
}
|
|
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;
|
|
3270
|
+
async function collectUserEntries(usersDirAbs, cwd) {
|
|
3271
|
+
const subjectDirs = await listSubdirs(usersDirAbs);
|
|
3272
|
+
return (await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
|
|
3273
|
+
const subject = basename(subjectDirAbs);
|
|
3274
|
+
const files = await listMdFilesShallow(subjectDirAbs);
|
|
3275
|
+
return (await Promise.all(files.map(async (file) => {
|
|
3276
|
+
const fm = await readFrontmatterOnly(file);
|
|
3277
|
+
if (!fm?.name || !fm?.description) return null;
|
|
3278
|
+
return {
|
|
3279
|
+
name: fm.name,
|
|
3280
|
+
description: fm.description,
|
|
3281
|
+
subject,
|
|
3282
|
+
relPath: relative(cwd, file)
|
|
3283
|
+
};
|
|
3284
|
+
}))).filter((e) => e !== null);
|
|
3285
|
+
}))).flat();
|
|
2377
3286
|
}
|
|
2378
3287
|
async function readFrontmatterOnly(filePath) {
|
|
2379
3288
|
let fh;
|
|
@@ -2404,77 +3313,189 @@ function parseFrontmatter(text) {
|
|
|
2404
3313
|
const result = FrontmatterSchema.safeParse(parsed);
|
|
2405
3314
|
return result.success ? result.data : null;
|
|
2406
3315
|
}
|
|
2407
|
-
function
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
}
|
|
2414
|
-
|
|
3316
|
+
function renderUserIndex(entries) {
|
|
3317
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
3318
|
+
for (const e of entries) {
|
|
3319
|
+
const list = bySubject.get(e.subject) ?? [];
|
|
3320
|
+
list.push(e);
|
|
3321
|
+
bySubject.set(e.subject, list);
|
|
3322
|
+
}
|
|
3323
|
+
const lines = ["### Users"];
|
|
3324
|
+
for (const subject of [...bySubject.keys()].sort()) {
|
|
3325
|
+
lines.push(`- **${subject}**`);
|
|
3326
|
+
for (const e of bySubject.get(subject) ?? []) lines.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
3327
|
+
}
|
|
3328
|
+
return lines.join("\n");
|
|
3329
|
+
}
|
|
3330
|
+
/**
|
|
3331
|
+
* One-time migration for agents created before `MEMORY.md` existed. If there is
|
|
3332
|
+
* no hand-maintained `.memory/MEMORY.md` yet but the agent has shared memory
|
|
3333
|
+
* files from the old frontmatter-indexed layout (`projects/`, `feedback/`,
|
|
3334
|
+
* `reference/`), derive a `MEMORY.md` from their frontmatter and write it once.
|
|
3335
|
+
* After that the agent owns the file — this never runs again for that agent and
|
|
3336
|
+
* never clobbers an existing index.
|
|
3337
|
+
*
|
|
3338
|
+
* Returns the seeded contents (also written to disk), or `null` when nothing
|
|
3339
|
+
* was seeded (index already present, or no legacy shared files). A write
|
|
3340
|
+
* failure propagates so the caller can log it; the read path then falls back to
|
|
3341
|
+
* whatever is on disk.
|
|
3342
|
+
*/
|
|
3343
|
+
async function seedMemoryIndexFile({ cwd }) {
|
|
3344
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3345
|
+
const indexPath = join(memoryDirAbs, MEMORY_INDEX_FILENAME);
|
|
3346
|
+
if (await stat(indexPath).catch(() => null)) return null;
|
|
3347
|
+
const perType = await Promise.all(LEGACY_SHARED_TYPES.map(async ({ dir, label }) => {
|
|
3348
|
+
const typeDirAbs = join(memoryDirAbs, dir);
|
|
3349
|
+
const files = [];
|
|
3350
|
+
await walkMdFiles(typeDirAbs, files);
|
|
3351
|
+
return {
|
|
3352
|
+
label,
|
|
3353
|
+
entries: (await Promise.all(files.map(async (file) => {
|
|
3354
|
+
const fm = await readFrontmatterOnly(file);
|
|
3355
|
+
if (!fm?.name || !fm?.description) return null;
|
|
3356
|
+
return {
|
|
3357
|
+
name: fm.name,
|
|
3358
|
+
description: fm.description,
|
|
3359
|
+
relPath: relative(cwd, file)
|
|
3360
|
+
};
|
|
3361
|
+
}))).filter((e) => !!e)
|
|
3362
|
+
};
|
|
3363
|
+
}));
|
|
3364
|
+
if (perType.every((group) => group.entries.length === 0)) return null;
|
|
3365
|
+
const content = renderSeededIndex(perType);
|
|
3366
|
+
await writeFile(indexPath, `${content}\n`, "utf-8");
|
|
3367
|
+
return content;
|
|
3368
|
+
}
|
|
3369
|
+
function renderSeededIndex(groups) {
|
|
2415
3370
|
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}`);
|
|
3371
|
+
for (const { label, entries } of groups) {
|
|
3372
|
+
if (entries.length === 0) continue;
|
|
3373
|
+
sections.push(`### ${label}`);
|
|
3374
|
+
const sorted = [...entries].sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
3375
|
+
for (const e of sorted) sections.push(`- [${e.name}](${e.relPath}) — ${e.description}`);
|
|
2434
3376
|
sections.push("");
|
|
2435
3377
|
}
|
|
2436
3378
|
return sections.join("\n").trimEnd();
|
|
2437
3379
|
}
|
|
3380
|
+
/**
|
|
3381
|
+
* The version at which the hand-maintained `MEMORY.md` layout was introduced.
|
|
3382
|
+
* Used to classify an unversioned `.memory/`: if it already has a `MEMORY.md`
|
|
3383
|
+
* it's on this layout (not the pre-MEMORY.md v1 frontmatter layout), so it
|
|
3384
|
+
* shouldn't be treated as v1 and re-seeded.
|
|
3385
|
+
*/
|
|
3386
|
+
const HAND_MAINTAINED_INDEX_VERSION = 2;
|
|
3387
|
+
const VERSION_FILENAME = ".version";
|
|
3388
|
+
const MEMORY_MIGRATIONS = [{
|
|
3389
|
+
from: 1,
|
|
3390
|
+
to: 2,
|
|
3391
|
+
apply: async ({ cwd }) => {
|
|
3392
|
+
await seedMemoryIndexFile({ cwd });
|
|
3393
|
+
}
|
|
3394
|
+
}];
|
|
3395
|
+
/**
|
|
3396
|
+
* The layout version of an agent's `.memory/`:
|
|
3397
|
+
* - `null` when there's no `.memory/` at all (a fresh agent is current by
|
|
3398
|
+
* construction; nothing to migrate).
|
|
3399
|
+
* - `1` when `.memory/` exists but carries no `.version` marker — i.e. it
|
|
3400
|
+
* predates versioning.
|
|
3401
|
+
* - otherwise the integer in `.memory/.version`.
|
|
3402
|
+
*/
|
|
3403
|
+
async function readMemoryVersion(cwd) {
|
|
3404
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3405
|
+
if (!(await stat(memoryDirAbs).catch(() => null))?.isDirectory()) return null;
|
|
3406
|
+
const raw = await readFile(join(memoryDirAbs, VERSION_FILENAME), "utf-8").catch(() => null);
|
|
3407
|
+
if (raw === null) return await stat(join(memoryDirAbs, MEMORY_INDEX_FILENAME)).then((s) => s.isFile()).catch(() => false) ? HAND_MAINTAINED_INDEX_VERSION : 1;
|
|
3408
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
3409
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
|
3410
|
+
}
|
|
3411
|
+
async function writeMemoryVersion(cwd, version) {
|
|
3412
|
+
await writeFile(join(cwd, MEMORY_DIRNAME, VERSION_FILENAME), `${version}\n`, "utf-8");
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Bring an agent's `.memory/` up to `CURRENT_MEMORY_VERSION` by applying the
|
|
3416
|
+
* ordered migrations. Runs on session start. No-op when there's no `.memory/`
|
|
3417
|
+
* yet or it's already current. Migrations must be idempotent, so a lost/unwritten
|
|
3418
|
+
* version marker (the file isn't committed by the harness) only costs a repeated
|
|
3419
|
+
* no-op, never corruption. Returns the `{ from, to }` actually applied, or
|
|
3420
|
+
* `null` when nothing ran.
|
|
3421
|
+
*/
|
|
3422
|
+
async function migrateMemory({ cwd }) {
|
|
3423
|
+
const from = await readMemoryVersion(cwd);
|
|
3424
|
+
if (from === null || from >= 2) return null;
|
|
3425
|
+
let version = from;
|
|
3426
|
+
while (version < 2) {
|
|
3427
|
+
const migration = MEMORY_MIGRATIONS.find((m) => m.from === version);
|
|
3428
|
+
if (!migration) break;
|
|
3429
|
+
await migration.apply({ cwd });
|
|
3430
|
+
version = migration.to;
|
|
3431
|
+
}
|
|
3432
|
+
await writeMemoryVersion(cwd, version);
|
|
3433
|
+
return {
|
|
3434
|
+
from,
|
|
3435
|
+
to: version
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
2438
3438
|
//#endregion
|
|
2439
3439
|
//#region src/extensions/memory.ts
|
|
2440
|
-
const log$
|
|
3440
|
+
const log$5 = logger.child({ module: "memory-extension" });
|
|
2441
3441
|
/**
|
|
2442
3442
|
* The standing instructions for the memory system. Always injected (even with
|
|
2443
|
-
*
|
|
3443
|
+
* no `MEMORY.md`) so the agent knows it can persist notes and how. `users/` is
|
|
2444
3444
|
* described by the platform memory extension, which is the only thing that can
|
|
2445
3445
|
* scope it to a person — here we just point at it.
|
|
2446
3446
|
*/
|
|
2447
3447
|
function memoryInstructions(cwd) {
|
|
2448
3448
|
return `## Memory across conversations
|
|
2449
3449
|
|
|
2450
|
-
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo
|
|
3450
|
+
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
3451
|
|
|
2452
3452
|
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
3453
|
|
|
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
|
|
3454
|
+
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\`.)
|
|
3455
|
+
|
|
3456
|
+
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:
|
|
3457
|
+
- 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.
|
|
3458
|
+
- 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.
|
|
3459
|
+
- 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.
|
|
3460
|
+
- 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.
|
|
3461
|
+
|
|
3462
|
+
Commit and push after editing \`.memory/\` to persist it.`;
|
|
2455
3463
|
}
|
|
2456
3464
|
function composeBlock$1({ cwd, index }) {
|
|
2457
3465
|
const instructions = memoryInstructions(cwd);
|
|
2458
3466
|
if (!index || index.length === 0) return instructions;
|
|
2459
|
-
|
|
3467
|
+
const header = `## Memory index (${indexSizeNote(index)})`;
|
|
3468
|
+
const warning = indexBudgetWarning(index);
|
|
3469
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3470
|
+
return `${instructions}\n\n${warning ? `${header}\n\n${warning}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2460
3471
|
}
|
|
2461
3472
|
const memoryExtension = (pi) => {
|
|
2462
3473
|
let cachedBlock = null;
|
|
2463
3474
|
pi.on("session_start", async (_event, ctx) => {
|
|
2464
3475
|
try {
|
|
2465
|
-
const
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
3476
|
+
const migrated = await migrateMemory({ cwd: ctx.cwd });
|
|
3477
|
+
if (migrated !== null) log$5.info({
|
|
3478
|
+
event: "memory_migrated",
|
|
3479
|
+
from: migrated.from,
|
|
3480
|
+
to: migrated.to
|
|
3481
|
+
}, "migrated memory layout to current version");
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
log$5.warn({
|
|
3484
|
+
err,
|
|
3485
|
+
event: "memory_migration_failed"
|
|
3486
|
+
}, "memory migration failed; continuing with existing index");
|
|
3487
|
+
}
|
|
3488
|
+
try {
|
|
3489
|
+
const index = await readMemoryIndexFile({ cwd: ctx.cwd });
|
|
2469
3490
|
cachedBlock = composeBlock$1({
|
|
2470
3491
|
cwd: ctx.cwd,
|
|
2471
3492
|
index
|
|
2472
3493
|
});
|
|
2473
3494
|
} catch (err) {
|
|
2474
|
-
log$
|
|
3495
|
+
log$5.warn({
|
|
2475
3496
|
err,
|
|
2476
3497
|
event: "memory_index_failed"
|
|
2477
|
-
}, "memory index
|
|
3498
|
+
}, "memory index read failed; injecting instructions only");
|
|
2478
3499
|
cachedBlock = memoryInstructions(ctx.cwd);
|
|
2479
3500
|
}
|
|
2480
3501
|
});
|
|
@@ -2485,7 +3506,7 @@ const memoryExtension = (pi) => {
|
|
|
2485
3506
|
};
|
|
2486
3507
|
//#endregion
|
|
2487
3508
|
//#region src/extensions/platform-memory.ts
|
|
2488
|
-
const log$
|
|
3509
|
+
const log$4 = logger.child({ module: "platform-memory-extension" });
|
|
2489
3510
|
/**
|
|
2490
3511
|
* Resolve the human on this turn via the API, keyed by the message id.
|
|
2491
3512
|
* `/sandbox/channel-context` only returns a sender for a platform-known
|
|
@@ -2498,13 +3519,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
|
|
|
2498
3519
|
async function resolveTurnUser(messageId) {
|
|
2499
3520
|
const client = sandboxClient();
|
|
2500
3521
|
if (!client) {
|
|
2501
|
-
log$
|
|
3522
|
+
log$4.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
|
|
2502
3523
|
return null;
|
|
2503
3524
|
}
|
|
2504
3525
|
try {
|
|
2505
3526
|
const res = await client["channel-context"].$get({ query: { messageId } });
|
|
2506
3527
|
if (!res.ok) {
|
|
2507
|
-
log$
|
|
3528
|
+
log$4.warn({
|
|
2508
3529
|
event: "resolve_turn_user_failed",
|
|
2509
3530
|
status: res.status
|
|
2510
3531
|
}, "channel-context returned non-ok; withholding user memory");
|
|
@@ -2517,7 +3538,7 @@ async function resolveTurnUser(messageId) {
|
|
|
2517
3538
|
displayName: sender.displayName
|
|
2518
3539
|
};
|
|
2519
3540
|
} catch (err) {
|
|
2520
|
-
log$
|
|
3541
|
+
log$4.warn({
|
|
2521
3542
|
err,
|
|
2522
3543
|
event: "resolve_turn_user_failed"
|
|
2523
3544
|
}, "failed to resolve current user; withholding user memory");
|
|
@@ -2533,9 +3554,12 @@ function slugifyName(name) {
|
|
|
2533
3554
|
function composeBlock({ index, user }) {
|
|
2534
3555
|
const instructions = `## Current user memory
|
|
2535
3556
|
|
|
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.`;
|
|
3557
|
+
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
3558
|
if (!index || index.length === 0) return instructions;
|
|
2538
|
-
|
|
3559
|
+
const warning = indexBudgetWarning(index);
|
|
3560
|
+
const header = `Memory index (${indexSizeNote(index)}):`;
|
|
3561
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3562
|
+
return `${instructions}\n\n${warning ? `${warning}\n\n${header}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2539
3563
|
}
|
|
2540
3564
|
/**
|
|
2541
3565
|
* Build the platform memory extension. `channelContext` is the per-turn ref
|
|
@@ -2556,15 +3580,12 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2556
3580
|
cachedBlock = composeBlock({
|
|
2557
3581
|
index: await buildMemoryIndex({
|
|
2558
3582
|
cwd: ctx.cwd,
|
|
2559
|
-
|
|
2560
|
-
kind: "user",
|
|
2561
|
-
userId: user.id
|
|
2562
|
-
}
|
|
3583
|
+
userId: user.id
|
|
2563
3584
|
}),
|
|
2564
3585
|
user
|
|
2565
3586
|
});
|
|
2566
3587
|
} catch (err) {
|
|
2567
|
-
log$
|
|
3588
|
+
log$4.warn({
|
|
2568
3589
|
err,
|
|
2569
3590
|
event: "user_memory_index_failed"
|
|
2570
3591
|
}, "user memory index build failed; skipping injection");
|
|
@@ -2579,7 +3600,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
2579
3600
|
}
|
|
2580
3601
|
//#endregion
|
|
2581
3602
|
//#region src/extensions/self-trace.ts
|
|
2582
|
-
const log$
|
|
3603
|
+
const log$3 = logger.child({ module: "self-trace-extension" });
|
|
2583
3604
|
/**
|
|
2584
3605
|
* Reports the agent's own execution as OpenTelemetry spans:
|
|
2585
3606
|
* agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
|
|
@@ -2604,7 +3625,7 @@ const selfTraceExtension = (pi) => {
|
|
|
2604
3625
|
sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
|
|
2605
3626
|
sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
|
|
2606
3627
|
const sc = sessionSpan.spanContext();
|
|
2607
|
-
log$
|
|
3628
|
+
log$3.info({
|
|
2608
3629
|
event: "self_trace_session_start",
|
|
2609
3630
|
trace_id: sc.traceId,
|
|
2610
3631
|
span_id: sc.spanId,
|
|
@@ -2716,13 +3737,13 @@ const selfTraceExtension = (pi) => {
|
|
|
2716
3737
|
* Lives in the harness package — soul.md is content from the agent's
|
|
2717
3738
|
* own git repo, not from the platform — so its handling stays here.
|
|
2718
3739
|
*/
|
|
2719
|
-
const log$
|
|
3740
|
+
const log$2 = logger.child({ module: "soul-extension" });
|
|
2720
3741
|
async function readSoul(cwd) {
|
|
2721
3742
|
try {
|
|
2722
3743
|
return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
|
|
2723
3744
|
} catch (err) {
|
|
2724
3745
|
if (err?.code === "ENOENT") return null;
|
|
2725
|
-
log$
|
|
3746
|
+
log$2.warn({
|
|
2726
3747
|
err,
|
|
2727
3748
|
event: "soul_read_failed"
|
|
2728
3749
|
}, "soul.md read failed");
|
|
@@ -2736,7 +3757,7 @@ function soulSection(cwd, soul) {
|
|
|
2736
3757
|
|
|
2737
3758
|
**\`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
3759
|
|
|
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.
|
|
3760
|
+
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
3761
|
|
|
2741
3762
|
${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
|
|
2742
3763
|
}
|
|
@@ -2754,13 +3775,23 @@ const soulExtension = (pi) => {
|
|
|
2754
3775
|
};
|
|
2755
3776
|
//#endregion
|
|
2756
3777
|
//#region src/extensions/subagent/index.ts
|
|
2757
|
-
const log$
|
|
3778
|
+
const log$1 = logger.child({ module: "subagent-ext" });
|
|
2758
3779
|
const MAX_TASKS = 8;
|
|
2759
3780
|
const TaskItem = Type.Object({
|
|
2760
3781
|
task: Type.String({ description: "The task to delegate to a subagent run." }),
|
|
3782
|
+
title: Type.String({
|
|
3783
|
+
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…\".",
|
|
3784
|
+
maxLength: 120
|
|
3785
|
+
}),
|
|
2761
3786
|
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
|
|
3787
|
+
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." })),
|
|
3788
|
+
timeoutMinutes: Type.Optional(Type.Integer({
|
|
3789
|
+
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.",
|
|
3790
|
+
minimum: 1,
|
|
3791
|
+
maximum: 360
|
|
3792
|
+
}))
|
|
2763
3793
|
});
|
|
3794
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
|
|
2764
3795
|
const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
|
|
2765
3796
|
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
3797
|
minItems: 1,
|
|
@@ -2775,7 +3806,9 @@ function buildTool(messageId) {
|
|
|
2775
3806
|
"Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
|
|
2776
3807
|
"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
3808
|
"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
|
|
3809
|
+
"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.",
|
|
3810
|
+
"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.",
|
|
3811
|
+
"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
3812
|
].join(" "),
|
|
2780
3813
|
promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
|
|
2781
3814
|
parameters: SubagentParams,
|
|
@@ -2791,29 +3824,41 @@ function buildTool(messageId) {
|
|
|
2791
3824
|
};
|
|
2792
3825
|
const spawnTasks = tasks.map((t) => ({
|
|
2793
3826
|
task: t.task,
|
|
3827
|
+
title: t.title ?? null,
|
|
2794
3828
|
persona: t.persona ?? null,
|
|
2795
|
-
model: t.model ?? null
|
|
3829
|
+
model: t.model ?? null,
|
|
3830
|
+
timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
|
|
2796
3831
|
}));
|
|
2797
3832
|
try {
|
|
2798
|
-
const
|
|
3833
|
+
const spawned = await postSubagentSpawn({
|
|
2799
3834
|
messageId,
|
|
2800
3835
|
tasks: spawnTasks
|
|
2801
3836
|
});
|
|
2802
|
-
|
|
3837
|
+
const { taskIds } = spawned;
|
|
3838
|
+
log$1.info({
|
|
2803
3839
|
event: "subagent_spawned",
|
|
2804
3840
|
count: taskIds.length
|
|
2805
3841
|
}, "subagent tasks queued");
|
|
2806
|
-
const
|
|
3842
|
+
const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
|
|
3843
|
+
const lines = taskIds.map((id, i) => {
|
|
3844
|
+
const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
|
|
3845
|
+
const conv = convByTask.get(id);
|
|
3846
|
+
return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
|
|
3847
|
+
}).join("\n");
|
|
3848
|
+
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
3849
|
return {
|
|
2808
3850
|
content: [{
|
|
2809
3851
|
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}`
|
|
3852
|
+
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
3853
|
}],
|
|
2812
|
-
details: {
|
|
3854
|
+
details: {
|
|
3855
|
+
taskIds,
|
|
3856
|
+
tasks: spawned.tasks
|
|
3857
|
+
}
|
|
2813
3858
|
};
|
|
2814
3859
|
} catch (err) {
|
|
2815
3860
|
const message = err instanceof Error ? err.message : String(err);
|
|
2816
|
-
log$
|
|
3861
|
+
log$1.warn({
|
|
2817
3862
|
err,
|
|
2818
3863
|
event: "subagent_spawn_failed"
|
|
2819
3864
|
}, "subagent spawn failed");
|
|
@@ -2830,31 +3875,37 @@ function buildTool(messageId) {
|
|
|
2830
3875
|
};
|
|
2831
3876
|
}
|
|
2832
3877
|
/**
|
|
2833
|
-
* Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
|
|
2834
3878
|
* The factory takes the session's channel context to resolve the originating
|
|
2835
3879
|
* messageId — the api links each spawned run to the conversation that message
|
|
2836
3880
|
* belongs to and rewakes it on completion (nothing about the parent is piped
|
|
2837
|
-
* from the sandbox beyond that id).
|
|
3881
|
+
* from the sandbox beyond that id). The tool is registered unconditionally at
|
|
3882
|
+
* session_start.
|
|
2838
3883
|
*/
|
|
2839
3884
|
function createSubagentExtension({ channelContext }) {
|
|
2840
3885
|
return (pi) => {
|
|
2841
3886
|
const messageId = extractMessageId(channelContext);
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
if (
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
}
|
|
3887
|
+
let registered = false;
|
|
3888
|
+
const registerOnce = () => {
|
|
3889
|
+
if (registered) return;
|
|
3890
|
+
registered = true;
|
|
3891
|
+
pi.registerTool(buildTool(messageId));
|
|
3892
|
+
log$1.info({ event: "subagent_enabled" }, "subagent tool registered");
|
|
3893
|
+
};
|
|
3894
|
+
pi.on("session_start", () => {
|
|
3895
|
+
registerOnce();
|
|
2848
3896
|
});
|
|
2849
3897
|
};
|
|
2850
3898
|
}
|
|
2851
3899
|
//#endregion
|
|
2852
3900
|
//#region src/extensions/tool-call-env.ts
|
|
2853
3901
|
const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
|
|
3902
|
+
function shellQuoteValue(value) {
|
|
3903
|
+
return quote([value]);
|
|
3904
|
+
}
|
|
2854
3905
|
function withToolCallId({ command, toolCallId }) {
|
|
2855
|
-
return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
|
|
3906
|
+
return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
|
|
2856
3907
|
}
|
|
2857
|
-
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
|
|
3908
|
+
const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
|
|
2858
3909
|
function stripPlatformExportsForDisplay(command) {
|
|
2859
3910
|
let c = command;
|
|
2860
3911
|
let m;
|
|
@@ -2872,214 +3923,6 @@ const toolCallEnvExtension = (pi) => {
|
|
|
2872
3923
|
});
|
|
2873
3924
|
};
|
|
2874
3925
|
//#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
3926
|
//#region src/extensions/background-tasks.ts
|
|
3084
3927
|
/**
|
|
3085
3928
|
* Background bash tasks as a pi extension.
|
|
@@ -3119,21 +3962,43 @@ const toolCallSummaryExtension = (pi) => {
|
|
|
3119
3962
|
* from the origin messageId in its channel context (`resolveConversationFromApi`)
|
|
3120
3963
|
* — and every run of the same conversation resolves to the same id, keeping the
|
|
3121
3964
|
* shared map correctly scoped across turns. `bg_*`, the completion wake, and the
|
|
3122
|
-
* next-session injection all filter
|
|
3123
|
-
*
|
|
3965
|
+
* next-session injection all filter by a **scope key** — the resolved
|
|
3966
|
+
* conversation id, or, when a session's conversation is unresolvable (a bare
|
|
3967
|
+
* CLI session, or a run whose channel-context ref carries no messageId), a
|
|
3968
|
+
* sentinel unique to that one session instance. Comparing on the raw
|
|
3969
|
+
* `conversationId` would bucket every unresolvable session together under
|
|
3970
|
+
* `null` and leak one's completion wake / status / next-session injection into
|
|
3971
|
+
* another; the sentinel keeps each isolated so an agent never sees or is woken
|
|
3972
|
+
* by a task from a different chat. Only the output log
|
|
3124
3973
|
* spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
|
|
3125
3974
|
* in memory; exit code and run state live on the in-memory task.
|
|
3126
3975
|
*
|
|
3127
|
-
* **
|
|
3128
|
-
*
|
|
3129
|
-
* respawn,
|
|
3130
|
-
*
|
|
3131
|
-
*
|
|
3132
|
-
*
|
|
3133
|
-
*
|
|
3134
|
-
*
|
|
3135
|
-
*
|
|
3136
|
-
*
|
|
3976
|
+
* **Cross-restart survival is OPT-IN, via `bg_run({ resumable: true })`.**
|
|
3977
|
+
* A non-resumable task's state lives only in the running harness process: a
|
|
3978
|
+
* harness restart (crash → supervisord respawn, `platform harness reload`, or
|
|
3979
|
+
* a sandbox recycle on idle timeout / redeploy / template rebuild) drops the
|
|
3980
|
+
* map and pi's exec children are reaped with it, and the task is gone — the
|
|
3981
|
+
* right behavior for a one-shot side-effecting command, which must never
|
|
3982
|
+
* silently re-run.
|
|
3983
|
+
*
|
|
3984
|
+
* A RESUMABLE task additionally checkpoints its *spec* (command, cwd, labels,
|
|
3985
|
+
* origin messageId — not its live output/exit state) to a durable, api-side
|
|
3986
|
+
* journal keyed by conversation (`/sandbox/bg-task-journal`, redis). On the
|
|
3987
|
+
* NEXT run's `session_start` — which usually lands on a *different*,
|
|
3988
|
+
* cold-provisioned sandbox, which is exactly why the journal is api-side and
|
|
3989
|
+
* not on the sandbox disk — the harness lists the journal and relaunches any
|
|
3990
|
+
* spec it isn't already running, keeping the original id and prepending a
|
|
3991
|
+
* `<resumed-after-restart>` banner so the agent knows it re-ran from scratch,
|
|
3992
|
+
* not continued. The spec is dropped from the journal when the task
|
|
3993
|
+
* finishes/kills. Because relaunch RE-EXECUTES the command, resumable is only
|
|
3994
|
+
* for idempotent, long-lived work (pollers, watchers, retry loops); the tool
|
|
3995
|
+
* description enforces this and the default is false.
|
|
3996
|
+
*
|
|
3997
|
+
* The idle-completion wake (a task finishing while the agent is idle) crosses
|
|
3998
|
+
* the sandbox → platform boundary via a fresh run (`bg-task-done`) for both
|
|
3999
|
+
* resumable and non-resumable tasks; the journal is a separate, additive layer
|
|
4000
|
+
* that only handles a task whose harness dies BEFORE it finishes. This stays
|
|
4001
|
+
* distinct from the scheduled-run (cron) system.
|
|
3137
4002
|
*/
|
|
3138
4003
|
const log = logger.child({ module: "background-tasks-ext" });
|
|
3139
4004
|
const ops = createLocalBashOperations();
|
|
@@ -3144,6 +4009,7 @@ const WATCHDOG_INTERVAL_MS = 3e4;
|
|
|
3144
4009
|
const KEEPALIVE_EVERY_MS = 6e4;
|
|
3145
4010
|
const KEEPALIVE_MAX_MS = 3600 * 1e3;
|
|
3146
4011
|
const STALL_HINT_AFTER_MS = 120 * 1e3;
|
|
4012
|
+
const PUBLISH_DEBOUNCE_MS = 300;
|
|
3147
4013
|
const MAX_LOG_BYTES = 100 * 1024 * 1024;
|
|
3148
4014
|
const DEFAULT_TAIL_LINES = 30;
|
|
3149
4015
|
const TAIL_READ_BYTES = 64 * 1024;
|
|
@@ -3151,11 +4017,12 @@ function taskLabel(meta) {
|
|
|
3151
4017
|
return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
|
|
3152
4018
|
}
|
|
3153
4019
|
let taskCounter = 0;
|
|
4020
|
+
let sessionScopeCounter = 0;
|
|
3154
4021
|
const tasks = /* @__PURE__ */ new Map();
|
|
3155
4022
|
let watchdogInterval = null;
|
|
3156
4023
|
let lastKeepaliveAt = 0;
|
|
3157
|
-
function
|
|
3158
|
-
return meta.
|
|
4024
|
+
function sameScope(meta, scopeKey) {
|
|
4025
|
+
return meta.scopeKey === scopeKey;
|
|
3159
4026
|
}
|
|
3160
4027
|
function logPath(id) {
|
|
3161
4028
|
return join(tasksDir(), `${id}.log`);
|
|
@@ -3268,6 +4135,10 @@ function createBackgroundTasksExtension({ channelContext }) {
|
|
|
3268
4135
|
return id;
|
|
3269
4136
|
});
|
|
3270
4137
|
}
|
|
4138
|
+
const unresolvedScopeSentinel = `unresolved:${process.pid.toString(36)}:${(sessionScopeCounter += 1).toString(36)}`;
|
|
4139
|
+
function scopeKey() {
|
|
4140
|
+
return conversationId ?? unresolvedScopeSentinel;
|
|
4141
|
+
}
|
|
3271
4142
|
let agentActive = false;
|
|
3272
4143
|
pi.on("agent_start", async () => {
|
|
3273
4144
|
agentActive = true;
|
|
@@ -3295,13 +4166,16 @@ ${recentOutput}
|
|
|
3295
4166
|
</background-task-finished>
|
|
3296
4167
|
Run bg_logs for the full output.
|
|
3297
4168
|
|
|
3298
|
-
This is a background-task completion, not a message from the user.
|
|
4169
|
+
This is a background-task completion, not a message from the user.
|
|
4170
|
+
For a routine or expected completion, output nothing.
|
|
4171
|
+
Only reply if the outcome changes what the user should know or do,
|
|
4172
|
+
or if you were explicitly waiting to report it.`,
|
|
3299
4173
|
display: false
|
|
3300
4174
|
};
|
|
3301
4175
|
}
|
|
3302
4176
|
async function notifyCompletion(meta) {
|
|
3303
4177
|
if (meta.notified) return;
|
|
3304
|
-
if (agentActive &&
|
|
4178
|
+
if (agentActive && sameScope(meta, scopeKey())) {
|
|
3305
4179
|
meta.notified = true;
|
|
3306
4180
|
pi.sendMessage(await taskDoneMessage(meta), {
|
|
3307
4181
|
triggerTurn: true,
|
|
@@ -3332,9 +4206,83 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3332
4206
|
});
|
|
3333
4207
|
} else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
|
|
3334
4208
|
}
|
|
3335
|
-
|
|
4209
|
+
const SNAPSHOT_TAIL_LINES = 40;
|
|
4210
|
+
let lastPublishedSignature = null;
|
|
4211
|
+
let publishInFlight = null;
|
|
4212
|
+
let publishQueued = false;
|
|
4213
|
+
function snapshotSignature(snapshot) {
|
|
4214
|
+
return JSON.stringify(snapshot.map((t) => ({
|
|
4215
|
+
id: t.id,
|
|
4216
|
+
state: t.state,
|
|
4217
|
+
exitCode: t.exitCode,
|
|
4218
|
+
killedReason: t.killedReason,
|
|
4219
|
+
outputTail: t.outputTail
|
|
4220
|
+
})));
|
|
4221
|
+
}
|
|
4222
|
+
async function doPublishSnapshot() {
|
|
4223
|
+
if (!messageId) return;
|
|
4224
|
+
const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
|
|
4225
|
+
try {
|
|
4226
|
+
const snapshot = await Promise.all(mine.map(async (t) => ({
|
|
4227
|
+
id: t.id,
|
|
4228
|
+
command: t.command,
|
|
4229
|
+
description: t.description,
|
|
4230
|
+
startedAt: t.startedAt,
|
|
4231
|
+
state: t.running ? "running" : "finished",
|
|
4232
|
+
exitCode: t.exitCode,
|
|
4233
|
+
killedReason: t.killedReason,
|
|
4234
|
+
outputTail: await tailLog(t.id, SNAPSHOT_TAIL_LINES)
|
|
4235
|
+
})));
|
|
4236
|
+
const signature = snapshotSignature(snapshot);
|
|
4237
|
+
if (signature === lastPublishedSignature) return;
|
|
4238
|
+
lastPublishedSignature = signature;
|
|
4239
|
+
postBackgroundTasksSnapshot({
|
|
4240
|
+
messageId,
|
|
4241
|
+
tasks: snapshot
|
|
4242
|
+
});
|
|
4243
|
+
} catch (err) {
|
|
4244
|
+
log.debug({
|
|
4245
|
+
err,
|
|
4246
|
+
event: "bg_tasks_snapshot_build_failed"
|
|
4247
|
+
}, "building bg-tasks snapshot failed");
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
async function publishSnapshotNow() {
|
|
4251
|
+
if (publishInFlight) {
|
|
4252
|
+
publishQueued = true;
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
publishInFlight = (async () => {
|
|
4256
|
+
try {
|
|
4257
|
+
do {
|
|
4258
|
+
publishQueued = false;
|
|
4259
|
+
await doPublishSnapshot();
|
|
4260
|
+
} while (publishQueued);
|
|
4261
|
+
} finally {
|
|
4262
|
+
publishInFlight = null;
|
|
4263
|
+
}
|
|
4264
|
+
})();
|
|
4265
|
+
await publishInFlight;
|
|
4266
|
+
}
|
|
4267
|
+
let publishTimer = null;
|
|
4268
|
+
function schedulePublishSnapshot() {
|
|
4269
|
+
if (publishTimer) return;
|
|
4270
|
+
publishTimer = setTimeout(() => {
|
|
4271
|
+
publishTimer = null;
|
|
4272
|
+
publishSnapshotNow();
|
|
4273
|
+
}, PUBLISH_DEBOUNCE_MS);
|
|
4274
|
+
publishTimer.unref?.();
|
|
4275
|
+
}
|
|
4276
|
+
async function flushPublishSnapshot() {
|
|
4277
|
+
if (publishTimer) {
|
|
4278
|
+
clearTimeout(publishTimer);
|
|
4279
|
+
publishTimer = null;
|
|
4280
|
+
}
|
|
4281
|
+
await publishSnapshotNow();
|
|
4282
|
+
}
|
|
4283
|
+
async function launchTask({ command, description, cwd, resumable = false, resumedFromJournal = false, id: providedId, startedAt: providedStartedAt }) {
|
|
3336
4284
|
taskCounter += 1;
|
|
3337
|
-
const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
|
|
4285
|
+
const id = providedId ?? `bg-${process.pid.toString(36)}-${taskCounter}`;
|
|
3338
4286
|
try {
|
|
3339
4287
|
await mkdir(tasksDir(), { recursive: true });
|
|
3340
4288
|
} catch (err) {
|
|
@@ -3350,7 +4298,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3350
4298
|
taskId: id
|
|
3351
4299
|
}, "bg task log write failed");
|
|
3352
4300
|
});
|
|
3353
|
-
const startedAt = Date.now();
|
|
4301
|
+
const startedAt = providedStartedAt ?? Date.now();
|
|
3354
4302
|
const meta = {
|
|
3355
4303
|
id,
|
|
3356
4304
|
command: stripPlatformExportsForDisplay(command),
|
|
@@ -3358,6 +4306,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3358
4306
|
logBytes: 0,
|
|
3359
4307
|
lastOutputAt: startedAt,
|
|
3360
4308
|
conversationId,
|
|
4309
|
+
scopeKey: scopeKey(),
|
|
3361
4310
|
messageId,
|
|
3362
4311
|
description,
|
|
3363
4312
|
notified: false,
|
|
@@ -3365,9 +4314,23 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3365
4314
|
controller: new AbortController(),
|
|
3366
4315
|
running: true,
|
|
3367
4316
|
exitCode: null,
|
|
3368
|
-
error: null
|
|
4317
|
+
error: null,
|
|
4318
|
+
resumable,
|
|
4319
|
+
cwd,
|
|
4320
|
+
resumedFromJournal
|
|
3369
4321
|
};
|
|
3370
4322
|
tasks.set(id, meta);
|
|
4323
|
+
if (resumable && messageId) putBackgroundTaskJournalSpec({
|
|
4324
|
+
messageId,
|
|
4325
|
+
spec: {
|
|
4326
|
+
id,
|
|
4327
|
+
command,
|
|
4328
|
+
cwd,
|
|
4329
|
+
description,
|
|
4330
|
+
startedAt,
|
|
4331
|
+
messageId
|
|
4332
|
+
}
|
|
4333
|
+
});
|
|
3371
4334
|
ops.exec(command, cwd, {
|
|
3372
4335
|
onData: (chunk) => {
|
|
3373
4336
|
meta.logBytes += chunk.length;
|
|
@@ -3393,6 +4356,11 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3393
4356
|
taskId: id,
|
|
3394
4357
|
exitCode: meta.exitCode
|
|
3395
4358
|
}, "bg task finished");
|
|
4359
|
+
if (meta.resumable && meta.messageId) deleteBackgroundTaskJournalSpec({
|
|
4360
|
+
messageId: meta.messageId,
|
|
4361
|
+
taskId: id
|
|
4362
|
+
});
|
|
4363
|
+
schedulePublishSnapshot();
|
|
3396
4364
|
await notifyCompletion(meta);
|
|
3397
4365
|
});
|
|
3398
4366
|
ensureWatchdog();
|
|
@@ -3400,19 +4368,55 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3400
4368
|
taskId: id,
|
|
3401
4369
|
conversationId
|
|
3402
4370
|
}, "bg task started");
|
|
4371
|
+
schedulePublishSnapshot();
|
|
3403
4372
|
return meta;
|
|
3404
4373
|
}
|
|
3405
4374
|
function knownTaskIds() {
|
|
3406
|
-
return [...tasks.values()].filter((t) =>
|
|
4375
|
+
return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
|
|
4376
|
+
}
|
|
4377
|
+
let journalChecked = false;
|
|
4378
|
+
async function rehydrateJournaledTasks() {
|
|
4379
|
+
if (!messageId || journalChecked) return;
|
|
4380
|
+
journalChecked = true;
|
|
4381
|
+
const specs = await listBackgroundTaskJournalSpecs({ messageId });
|
|
4382
|
+
if (specs.length === 0) return;
|
|
4383
|
+
for (const spec of specs) {
|
|
4384
|
+
const live = tasks.get(spec.id);
|
|
4385
|
+
if (live && sameScope(live, scopeKey())) continue;
|
|
4386
|
+
log.info({
|
|
4387
|
+
taskId: spec.id,
|
|
4388
|
+
conversationId
|
|
4389
|
+
}, "relaunching journaled resumable bg task after restart");
|
|
4390
|
+
const meta = await launchTask({
|
|
4391
|
+
command: spec.command,
|
|
4392
|
+
description: spec.description,
|
|
4393
|
+
cwd: spec.cwd,
|
|
4394
|
+
resumable: true,
|
|
4395
|
+
resumedFromJournal: true,
|
|
4396
|
+
id: spec.id,
|
|
4397
|
+
startedAt: spec.startedAt
|
|
4398
|
+
});
|
|
4399
|
+
try {
|
|
4400
|
+
const stream = createWriteStream(logPath(meta.id), { flags: "a" });
|
|
4401
|
+
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`);
|
|
4402
|
+
stream.end();
|
|
4403
|
+
} catch (err) {
|
|
4404
|
+
log.debug({
|
|
4405
|
+
err,
|
|
4406
|
+
taskId: meta.id
|
|
4407
|
+
}, "resume banner write failed");
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
3407
4410
|
}
|
|
3408
4411
|
pi.on("session_start", async () => {
|
|
3409
|
-
if (tasks.size === 0) return;
|
|
4412
|
+
if (tasks.size === 0 && (!messageId || journalChecked)) return;
|
|
3410
4413
|
await ensureConversationId();
|
|
3411
|
-
|
|
4414
|
+
await rehydrateJournaledTasks();
|
|
4415
|
+
for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
|
|
3412
4416
|
tasks.delete(id);
|
|
3413
4417
|
await unlink(logPath(id)).catch(() => {});
|
|
3414
4418
|
}
|
|
3415
|
-
const unnotified = [...tasks.values()].filter((t) =>
|
|
4419
|
+
const unnotified = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && !t.notified && !t.running);
|
|
3416
4420
|
for (const meta of unnotified) {
|
|
3417
4421
|
meta.notified = true;
|
|
3418
4422
|
pi.sendMessage(await taskDoneMessage(meta));
|
|
@@ -3421,7 +4425,9 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3421
4425
|
conversationId,
|
|
3422
4426
|
count: unnotified.length
|
|
3423
4427
|
}, "injected completed bg tasks at session_start");
|
|
3424
|
-
if ([...tasks.values()].some((t) =>
|
|
4428
|
+
if ([...tasks.values()].some((t) => sameScope(t, scopeKey()) && t.running)) ensureWatchdog();
|
|
4429
|
+
lastPublishedSignature = null;
|
|
4430
|
+
await flushPublishSnapshot();
|
|
3425
4431
|
});
|
|
3426
4432
|
function err(text) {
|
|
3427
4433
|
return {
|
|
@@ -3438,11 +4444,11 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3438
4444
|
}
|
|
3439
4445
|
function resolveTask(taskId) {
|
|
3440
4446
|
const exact = tasks.get(taskId);
|
|
3441
|
-
if (exact &&
|
|
4447
|
+
if (exact && sameScope(exact, scopeKey())) return {
|
|
3442
4448
|
error: null,
|
|
3443
4449
|
meta: exact
|
|
3444
4450
|
};
|
|
3445
|
-
const matches = [...tasks.values()].filter((t) =>
|
|
4451
|
+
const matches = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && t.id.startsWith(taskId));
|
|
3446
4452
|
if (matches.length === 1) return {
|
|
3447
4453
|
error: null,
|
|
3448
4454
|
meta: matches[0]
|
|
@@ -3451,7 +4457,7 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3451
4457
|
return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
|
|
3452
4458
|
}
|
|
3453
4459
|
function listTasks() {
|
|
3454
|
-
const mine = [...tasks.values()].filter((t) =>
|
|
4460
|
+
const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
|
|
3455
4461
|
if (mine.length === 0) return "No background tasks.";
|
|
3456
4462
|
return mine.map((t) => {
|
|
3457
4463
|
const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
|
|
@@ -3464,24 +4470,27 @@ This is a background-task completion, not a message from the user. If it needs n
|
|
|
3464
4470
|
const bgRun = {
|
|
3465
4471
|
name: "bg_run",
|
|
3466
4472
|
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",
|
|
4473
|
+
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.",
|
|
4474
|
+
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
4475
|
parameters: Type.Object({
|
|
3470
4476
|
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)." }))
|
|
4477
|
+
description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." })),
|
|
4478
|
+
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
4479
|
}),
|
|
3473
4480
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3474
4481
|
await ensureConversationId();
|
|
3475
|
-
const { command, description = null } = params;
|
|
4482
|
+
const { command, description = null, resumable = false } = params;
|
|
3476
4483
|
const meta = await launchTask({
|
|
3477
4484
|
command,
|
|
3478
4485
|
description,
|
|
3479
|
-
cwd: ctx.cwd
|
|
4486
|
+
cwd: ctx.cwd,
|
|
4487
|
+
resumable
|
|
3480
4488
|
});
|
|
4489
|
+
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
4490
|
return {
|
|
3482
4491
|
content: [{
|
|
3483
4492
|
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}"}
|
|
4493
|
+
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}`
|
|
3485
4494
|
}],
|
|
3486
4495
|
details: {}
|
|
3487
4496
|
};
|
|
@@ -3601,6 +4610,7 @@ const all = [
|
|
|
3601
4610
|
localToolsExtension,
|
|
3602
4611
|
toolCallEnvExtension,
|
|
3603
4612
|
bashDefaultTimeoutExtension,
|
|
4613
|
+
diskGuardExtension,
|
|
3604
4614
|
toolCallSummaryExtension
|
|
3605
4615
|
];
|
|
3606
4616
|
/**
|
|
@@ -3619,7 +4629,8 @@ function platformExtensions({ sessionId, channelContext }) {
|
|
|
3619
4629
|
selfTraceExtension,
|
|
3620
4630
|
createBackgroundTasksExtension({ channelContext }),
|
|
3621
4631
|
createSubagentExtension({ channelContext }),
|
|
3622
|
-
createContextManagementExtension()
|
|
4632
|
+
createContextManagementExtension(),
|
|
4633
|
+
resourcePressureWarningExtension
|
|
3623
4634
|
];
|
|
3624
4635
|
}
|
|
3625
4636
|
//#endregion
|