billion-context-dsh 0.2.0 → 0.2.2

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.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  toolPairingBalancedAfter,
17
17
  toolPairingBalancedBefore
18
18
  } from "@deepseek-ai/dsh-compaction";
19
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
19
+ import { createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
20
20
  import { defaultCountTokens } from "acp-kernel";
21
21
 
22
22
  // src/messages.ts
@@ -347,7 +347,198 @@ function isCheckpointNode(event) {
347
347
  const source = event.data.source;
348
348
  return source?.plugin === "compact";
349
349
  }
350
+ function toolCallIdsOfEvent(event) {
351
+ if (event.type !== "assistant/message") return [];
352
+ const content = event.data.message?.content;
353
+ if (!Array.isArray(content)) return [];
354
+ const ids = [];
355
+ for (const block of content) {
356
+ if (block === null || typeof block !== "object") continue;
357
+ const b = block;
358
+ if (b.type === "tool-call" && typeof b.id === "string") ids.push(b.id);
359
+ }
360
+ return ids;
361
+ }
362
+ function toolCallIdOfResultEvent(event) {
363
+ if (event.type !== "tool/result") return null;
364
+ const message = event.data.message;
365
+ const block = Array.isArray(message?.content) ? message.content.find((candidate) => candidate?.type === "tool-result") : void 0;
366
+ const id = block?.toolCallId ?? message?.source?.callId;
367
+ return typeof id === "string" ? id : null;
368
+ }
369
+ function assistantProviderModel(event) {
370
+ if (event.type === "assistant/message") {
371
+ const message = event.data.message;
372
+ return {
373
+ provider: typeof message?.source?.provider === "string" ? message.source.provider : "billion-context-dsh",
374
+ model: typeof message?.source?.model === "string" ? message.source.model : "surface-prune"
375
+ };
376
+ }
377
+ return { provider: "billion-context-dsh", model: "surface-prune" };
378
+ }
379
+ function hideSurfaceSeqs(session, seqs, provider, model, text) {
380
+ if (seqs.length === 0) return;
381
+ const start = seqs[0];
382
+ const end = seqs[seqs.length - 1];
383
+ let shadowedTokenCount = 0;
384
+ for (const seq of seqs) {
385
+ const event = session.events[seq];
386
+ if (event !== void 0) shadowedTokenCount += defaultCountTokens(extractEventText(event));
387
+ }
388
+ session.append("compaction/prune", {
389
+ shadowedRange: { start, end },
390
+ shadowedSeqs: [...seqs],
391
+ shadowedTokenCount
392
+ });
393
+ if (text !== void 0) {
394
+ session.append("user/message", createUserMessage({
395
+ content: [{ type: "text", text }],
396
+ source: { kind: "plugin", plugin: "billion-context-dsh" }
397
+ }), {
398
+ surfaceOp: { op: "replace", start, end },
399
+ sourceEventSeqs: [...seqs]
400
+ });
401
+ return;
402
+ }
403
+ session.append("assistant/message", {
404
+ turn: findOpenTurn(session.events) ?? 0,
405
+ step: 0,
406
+ message: createAssistantMessage({ content: [], source: { provider, model } })
407
+ }, {
408
+ surfaceOp: { op: "replace", start, end },
409
+ sourceEventSeqs: [...seqs]
410
+ });
411
+ }
412
+ function hideCompressToolPair(session, callId, resultSeq) {
413
+ let callSeq = null;
414
+ for (const event of session.events) {
415
+ if (event.type !== "assistant/message") continue;
416
+ if (toolCallIdsOfEvent(event).includes(callId)) {
417
+ callSeq = event.seq;
418
+ break;
419
+ }
420
+ }
421
+ if (callSeq === null) return false;
422
+ const callNodeIds = toolCallIdsOfEvent(session.events[callSeq]);
423
+ if (callNodeIds.length !== 1 || callNodeIds[0] !== callId) return false;
424
+ let resolvedResultSeq = resultSeq ?? null;
425
+ if (resolvedResultSeq === null) {
426
+ for (const event of session.events) {
427
+ if (event.type === "tool/result" && toolCallIdOfResultEvent(event) === callId) {
428
+ resolvedResultSeq = event.seq;
429
+ break;
430
+ }
431
+ }
432
+ }
433
+ if (resolvedResultSeq === null) return false;
434
+ const nodes = session.surface.nodes;
435
+ const startIdx = nodes.indexOf(callSeq);
436
+ const endIdx = nodes.indexOf(resolvedResultSeq);
437
+ if (startIdx < 0 || endIdx < 0 || endIdx - startIdx !== 1) return false;
438
+ const { provider, model } = assistantProviderModel(session.events[callSeq]);
439
+ const resultEvent = session.events[resolvedResultSeq];
440
+ const resultText = resultEvent === void 0 ? "" : extractEventText(resultEvent);
441
+ hideSurfaceSeqs(session, [callSeq, resolvedResultSeq], provider, model, resultText.trim().length > 0 ? resultText : void 0);
442
+ return true;
443
+ }
444
+ function stripOrphanedSurfaceToolMessages(session, inFlightCallIds = /* @__PURE__ */ new Set()) {
445
+ const nodes = session.surface.nodes;
446
+ const callIdsBySeq = /* @__PURE__ */ new Map();
447
+ const open = /* @__PURE__ */ new Map();
448
+ const orphanResultSeqs = [];
449
+ const brokenResults = /* @__PURE__ */ new Map();
450
+ for (let index = 0; index < nodes.length; index += 1) {
451
+ const seq = nodes[index];
452
+ const event = session.events[seq];
453
+ if (event === void 0) continue;
454
+ if (event.type === "assistant/message") {
455
+ const ids = toolCallIdsOfEvent(event);
456
+ if (ids.length === 0) continue;
457
+ callIdsBySeq.set(seq, ids);
458
+ for (const id of ids) {
459
+ if (!open.has(id)) open.set(id, { seq, index });
460
+ }
461
+ } else if (event.type === "tool/result") {
462
+ const id = toolCallIdOfResultEvent(event);
463
+ if (id === null) continue;
464
+ const call = open.get(id);
465
+ if (call === void 0) {
466
+ orphanResultSeqs.push(seq);
467
+ continue;
468
+ }
469
+ const callNodeIds = callIdsBySeq.get(call.seq);
470
+ let adjacent = false;
471
+ if (callNodeIds !== void 0) {
472
+ adjacent = true;
473
+ for (let mid = call.index + 1; mid < index; mid += 1) {
474
+ const midEvent = session.events[nodes[mid]];
475
+ if (midEvent === void 0 || midEvent.type !== "tool/result") {
476
+ adjacent = false;
477
+ break;
478
+ }
479
+ const midId = toolCallIdOfResultEvent(midEvent);
480
+ if (midId === null || !callNodeIds.includes(midId)) {
481
+ adjacent = false;
482
+ break;
483
+ }
484
+ }
485
+ }
486
+ open.delete(id);
487
+ if (!adjacent) brokenResults.set(seq, call.seq);
488
+ }
489
+ }
490
+ const brokenIdsByCallSeq = /* @__PURE__ */ new Map();
491
+ for (const [resultSeq, callSeq] of brokenResults) {
492
+ const id = toolCallIdOfResultEvent(session.events[resultSeq]);
493
+ if (id !== null) {
494
+ const list = brokenIdsByCallSeq.get(callSeq) ?? [];
495
+ list.push(id);
496
+ brokenIdsByCallSeq.set(callSeq, list);
497
+ }
498
+ }
499
+ const hiddenSet = new Set(orphanResultSeqs);
500
+ for (const resultSeq of brokenResults.keys()) hiddenSet.add(resultSeq);
501
+ for (const [callSeq, ids] of callIdsBySeq) {
502
+ const brokenIds = brokenIdsByCallSeq.get(callSeq);
503
+ const allUnpaired = !ids.some((candidate) => inFlightCallIds.has(candidate)) && ids.every((candidate) => open.has(candidate) || brokenIds?.includes(candidate) === true);
504
+ if (allUnpaired) hiddenSet.add(callSeq);
505
+ }
506
+ const hidden = [...hiddenSet].sort((a, b) => a - b);
507
+ let count = 0;
508
+ for (const seq of hidden) {
509
+ const event = session.events[seq];
510
+ if (event === void 0) continue;
511
+ const { provider, model } = assistantProviderModel(event);
512
+ hideSurfaceSeqs(session, [seq], provider, model);
513
+ count += 1;
514
+ }
515
+ return count;
516
+ }
517
+ function openToolCallIds(session) {
518
+ const open = /* @__PURE__ */ new Set();
519
+ for (const seq of session.surface.nodes) {
520
+ const event = session.events[seq];
521
+ if (event === void 0) continue;
522
+ if (event.type === "assistant/message") {
523
+ for (const id of toolCallIdsOfEvent(event)) open.add(id);
524
+ } else if (event.type === "tool/result") {
525
+ const id = toolCallIdOfResultEvent(event);
526
+ if (id !== null) open.delete(id);
527
+ }
528
+ }
529
+ return open;
530
+ }
531
+ function deferCompressPairHide(session, callId, resultSeq, onError) {
532
+ queueMicrotask(() => {
533
+ try {
534
+ hideCompressToolPair(session, callId, resultSeq);
535
+ } catch (error) {
536
+ onError?.(error);
537
+ }
538
+ });
539
+ }
350
540
  function buildCompressibleSeqRanges(session, opts = {}) {
541
+ stripOrphanedSurfaceToolMessages(session);
351
542
  const nodes = session.surface.nodes;
352
543
  const preserve = opts.preserveRecent ?? 5;
353
544
  const protectedSeqs = /* @__PURE__ */ new Set();
@@ -582,17 +773,24 @@ function kernelConfigFor(input) {
582
773
 
583
774
  // src/nudge.ts
584
775
  import {
585
- defaultCountTokens as defaultCountTokens2
776
+ COMPRESS_PHILOSOPHY as COMPRESS_PHILOSOPHY2,
777
+ TIER2_DISTILL_RULES as TIER2_DISTILL_RULES2,
778
+ TIER3_CONDENSE_RULES as TIER3_CONDENSE_RULES2,
779
+ defaultCountTokens as defaultCountTokens2,
780
+ renderNudgeText
586
781
  } from "acp-kernel";
587
782
  import { createUserMessage as createUserMessage2 } from "@deepseek-ai/dsh-llm";
588
783
 
589
784
  // src/prompts.ts
590
- import { COMPRESS_PHILOSOPHY } from "acp-kernel";
785
+ import { COMPRESS_PHILOSOPHY, HOW_TO_COMPRESS_RULES, TIER2_DISTILL_RULES, TIER3_CONDENSE_RULES } from "acp-kernel";
591
786
  var NUDGE_ALLOWED = {
592
- normal: /* @__PURE__ */ new Set(["pct"]),
593
- emergency: /* @__PURE__ */ new Set(["pct"]),
787
+ normal: /* @__PURE__ */ new Set(["pct", "philosophy"]),
788
+ emergency: /* @__PURE__ */ new Set(["pct", "philosophy"]),
594
789
  guidance: /* @__PURE__ */ new Set(),
595
- tier: /* @__PURE__ */ new Set(["tier", "count", "prevTier", "tokens", "seqs"])
790
+ tier: /* @__PURE__ */ new Set(["tier", "count", "prevTier", "tokens", "seqs"]),
791
+ breakdown: /* @__PURE__ */ new Set(["system", "tool", "summaries", "code", "text"]),
792
+ growth: /* @__PURE__ */ new Set(["growth"]),
793
+ tip: /* @__PURE__ */ new Set()
596
794
  };
597
795
  var RANGE_TABLE_ALLOWED = {
598
796
  header: /* @__PURE__ */ new Set(["surface"]),
@@ -606,7 +804,7 @@ var TOOLS_ALLOWED = {
606
804
  searchContext: /* @__PURE__ */ new Set(),
607
805
  acpStatus: /* @__PURE__ */ new Set()
608
806
  };
609
- var SYSTEM_ALLOWED = /* @__PURE__ */ new Set(["philosophy"]);
807
+ var SYSTEM_ALLOWED = /* @__PURE__ */ new Set(["philosophy", "howToCompressRules", "tier2DistillRules", "tier3CondenseRules"]);
610
808
  function validateTemplate(template, allowed, path) {
611
809
  const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
612
810
  let match;
@@ -650,14 +848,24 @@ function resolvePrompts(input) {
650
848
  };
651
849
  }
652
850
  function renderSystemPrompt(prompts) {
653
- return renderTemplate(prompts.systemPromptTemplate, { philosophy: COMPRESS_PHILOSOPHY });
851
+ return renderTemplate(prompts.systemPromptTemplate, {
852
+ philosophy: COMPRESS_PHILOSOPHY,
853
+ howToCompressRules: HOW_TO_COMPRESS_RULES,
854
+ tier2DistillRules: TIER2_DISTILL_RULES,
855
+ tier3CondenseRules: TIER3_CONDENSE_RULES
856
+ });
654
857
  }
655
858
  var DEFAULT_PROMPTS = {
656
859
  nudge: {
657
- normal: "Context usage is at {pct}%. This is a suggestion, not a requirement \u2014 you decide whether and when to compress.",
658
- emergency: "\u26A0\uFE0F Context usage is at {pct}% of the window \u2014 nearly full. Consider compressing consumed ranges soon so working context stays available; the choice and timing are yours.",
659
- guidance: "Compress by need, not by percentage: replace only ranges you have genuinely consumed, with dense self-contained summaries.",
660
- tier: "Tier {tier}: {count} tier-{prevTier} block(s) distillable ({tokens} tokens) \u2014 compress their summary node(s) [seqs {seqs}] to reclaim the original messages."
860
+ // 与 kernel nudge-text.ts EFFICIENCY_NOTE 逐字对齐——不含 "Context usage is at X%"
861
+ // 陈述(usage 只通过 breakdown 传达);{pct} 仍可用作自定义占位符。
862
+ normal: "This is an efficiency nudge to compress early and keep context lean \u2014 not an overflow warning. A separate, stronger alert will appear if the context is actually full.\n\n{philosophy}",
863
+ emergency: "\u26A0\uFE0F Context limit reached \u2014 compress now. Prioritize consumed tool outputs.\n\n{philosophy}",
864
+ guidance: HOW_TO_COMPRESS_RULES,
865
+ tier: "Tier {tier}: {count} tier-{prevTier} block(s) distillable ({tokens} tokens) \u2014 compress their summary node(s) [seqs {seqs}] to reclaim the original messages.",
866
+ breakdown: "Context breakdown: {system}K system | {tool}K tool | {summaries}K summaries | {code}K code | {text}K text",
867
+ growth: "+{growth}K since last nudge",
868
+ tip: "\u{1F4A1} Compress all ranges in one call (pass multiple content entries: `content: [{...}, {...}]`)."
661
869
  },
662
870
  rangeTable: {
663
871
  header: "Surface: {surface}",
@@ -673,10 +881,26 @@ var DEFAULT_PROMPTS = {
673
881
  },
674
882
  systemPromptTemplate: `Active Context Pruning \u2014 model-driven context management
675
883
 
676
- YOU decide whether and when to compress context. Nothing forces you: the injected "nudge" is a suggestion, not an order, and you may ignore it when compression would not help. Compress only ranges you have genuinely consumed (read tool outputs, finished explorations, superseded steps) that the current work no longer needs verbatim.
884
+ YOU decide whether and when to compress context. The nudge is an efficiency notification: when you see one, consider which ranges you have genuinely consumed and could summarise to keep working context lean.
677
885
 
678
886
  {philosophy}
679
887
 
888
+ WHEN TO COMPRESS:
889
+ - A sub-agent or delegated task has returned a large result that you have already extracted the key facts from.
890
+ - Verbose command output (build/test logs, git diff, directory listings) where you have already used the information you need.
891
+ - Exploration that led nowhere.
892
+ - Repeated reads of the same file or repeated status checks once the decision is recorded.
893
+ - Resolved discussion threads where a decision has been captured in summary or in code.
894
+ - Intermediate steps of a completed multi-step task, once the final result is recorded.
895
+ - A task phase has ended \u2014 bug hunt complete, root cause found, exploration done, research sprint wrapped.
896
+
897
+ WHEN NOT TO COMPRESS:
898
+ - Content the current step is actively reading or reasoning about.
899
+ - Important user messages \u2014 preserve their exact intent, constraints, and acceptance criteria.
900
+ - Protected tool outputs \u2014 hard-excluded from compression ranges, survive intact in visible context.
901
+
902
+ {howToCompressRules}
903
+
680
904
  Compression tools (refs are SURFACE SEQS, not ids):
681
905
  - compress: replace one or more seq ranges, each with your own dense summary. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated segments in one call (each entry becomes its own block): compress({ content: [{ startSeq: 1, endSeq: 5, summary: '...' }, { startSeq: 12, endSeq: 18, summary: '...' }] }). Keep ranges disjoint \u2014 overlapping entries in one batch are skipped. Edges are auto-balanced to tool-call/result boundaries; a trailing #callId fragment in a seq is ignored. Seq refs must be on the current surface: seqs from older nudges or earlier compresses go stale as the surface moves, so a stale span is auto-remapped to its still-live remainder (the result reports the adjusted span), a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.
682
906
  - decompress: recover a compressed block's original content, read-only. decompress({ blockId }).
@@ -685,6 +909,10 @@ Compression tools (refs are SURFACE SEQS, not ids):
685
909
 
686
910
  Tiered compression: each compressed block appears on the surface as one summary node. Compressing that node again DISTILLS the block (tier 2): the parent summary folds into your new summary and the original messages are freed. Distilling a tier-2 block yields tier 3. Distill when a summary itself is consumed \u2014 decompress on the tier-2 block recovers the full originals.
687
911
 
912
+ {tier2DistillRules}
913
+
914
+ {tier3CondenseRules}
915
+
688
916
  When you write a summary, it becomes the ONLY record of that range: keep file paths, signatures, exact values, decisions, and error strings verbatim so a later reader (or you, after decompress) can continue without the original. Never reuse historical seqs \u2014 the surface moves as messages land and compress; verify with acp_status.`
689
917
  };
690
918
  var DEFAULT_RESOLVED = DEFAULT_PROMPTS;
@@ -746,12 +974,84 @@ function buildNudge(agent, env, lastNudgeTurn) {
746
974
  return { message, emergency };
747
975
  }
748
976
  function buildNudgeText(nudge, emergency, session, prompts = DEFAULT_RESOLVED) {
977
+ if (prompts.nudge !== DEFAULT_RESOLVED.nudge) {
978
+ return renderNudgeFromTemplates(nudge, emergency, session, prompts);
979
+ }
980
+ const rendered = renderNudgeText(nudge);
981
+ return adaptKernelNudgeToSeq(rendered.text, nudge, session, prompts);
982
+ }
983
+ function adaptKernelNudgeToSeq(text, nudge, session, prompts) {
984
+ let out = text;
985
+ if ((nudge.tier === 2 || nudge.tier === 3) && (nudge.tierTargetBlocks?.length ?? 0) > 0) {
986
+ out = replaceTierTrigger(out, nudge, session, prompts);
987
+ } else if (out.includes('"startId"')) {
988
+ out = replaceEmergencyExample(out);
989
+ }
990
+ const seqTable = rangeTable(session, prompts);
991
+ if (seqTable !== "") out = replaceRangesStr(out, seqTable);
992
+ return out;
993
+ }
994
+ function replaceRangesStr(text, seqTable) {
995
+ const match = text.match(/\n\n(?:Compressible ranges \(|\[No specific ranges detected)/);
996
+ if (!match) return text;
997
+ const start = match.index;
998
+ const rest = text.slice(start + 2);
999
+ const next = rest.match(/\n\n/);
1000
+ const end = next !== null ? start + 2 + next.index : text.length;
1001
+ const before = text.slice(0, start);
1002
+ const after = text.slice(end);
1003
+ return before + "\n" + seqTable + after;
1004
+ }
1005
+ function replaceTierTrigger(text, nudge, session, prompts) {
1006
+ const start = text.search(/\n\n(?:\[TIER \d|\[EMERGENCY — TIER \d)/);
1007
+ if (start === -1) return text;
1008
+ const rest = text.slice(start + 2);
1009
+ const next = rest.match(/\n\nHOW TO COMPRESS/);
1010
+ const end = next !== null ? start + 2 + next.index : text.length;
1011
+ const targets = nudge.tierTargetBlocks;
1012
+ const summarySeqs = targets.map((block) => summarySeqOfKernelBlock(session, block.blockId)).filter((seq) => seq !== null);
1013
+ const pending = nudge.tier === 2 ? nudge.breakdown?.pendingT2 : nudge.breakdown?.pendingT3;
1014
+ const tokens = typeof pending === "number" ? pending : 0;
1015
+ const tierValue = nudge.tier === null ? 2 : nudge.tier;
1016
+ const tierLine = renderTemplate(prompts.nudge.tier, {
1017
+ tier: tierValue,
1018
+ count: targets.length,
1019
+ prevTier: tierValue - 1,
1020
+ tokens,
1021
+ seqs: summarySeqs.join(", ")
1022
+ });
1023
+ return text.slice(0, start) + "\n\n" + tierLine + text.slice(end);
1024
+ }
1025
+ function replaceEmergencyExample(text) {
1026
+ const start = text.search(/\n\n\{ "topic":/);
1027
+ if (start === -1) return text;
1028
+ const rest = text.slice(start + 2);
1029
+ const next = rest.match(/\n\nCompressible ranges |\n\n\[No specific/);
1030
+ const end = next !== null ? start + 2 + next.index : text.length;
1031
+ return text.slice(0, start) + "\n\ncompress({ content: [{ startSeq, endSeq, summary }] }) \u2014 use the seqs from the range table above." + text.slice(end);
1032
+ }
1033
+ function renderNudgeFromTemplates(nudge, emergency, session, prompts) {
749
1034
  const pct = Math.round(Math.min(nudge.contextUsage, 1) * 100);
750
1035
  const frame = renderTemplate(
751
1036
  emergency ? prompts.nudge.emergency : prompts.nudge.normal,
752
- { pct }
1037
+ { pct, philosophy: COMPRESS_PHILOSOPHY2 }
753
1038
  );
754
1039
  const parts = [frame];
1040
+ if (nudge.contextBreakdown) {
1041
+ const bd = nudge.contextBreakdown;
1042
+ const breakdown = renderTemplate(prompts.nudge.breakdown, {
1043
+ system: Math.round(bd.system / 1e3),
1044
+ tool: Math.round(bd.tool / 1e3),
1045
+ summaries: Math.round(bd.summaries / 1e3),
1046
+ code: Math.round(bd.code / 1e3),
1047
+ text: Math.round(bd.text / 1e3)
1048
+ });
1049
+ if (breakdown !== "") parts.push("", breakdown);
1050
+ if (bd.growth > 0) {
1051
+ const growth = renderTemplate(prompts.nudge.growth, { growth: Math.round(bd.growth / 1e3) });
1052
+ if (growth !== "") parts.push(growth);
1053
+ }
1054
+ }
755
1055
  if (prompts.nudge.guidance !== "") parts.push("", prompts.nudge.guidance);
756
1056
  if ((nudge.tier === 2 || nudge.tier === 3) && (nudge.tierTargetBlocks?.length ?? 0) > 0) {
757
1057
  const targets = nudge.tierTargetBlocks;
@@ -766,8 +1066,12 @@ function buildNudgeText(nudge, emergency, session, prompts = DEFAULT_RESOLVED) {
766
1066
  seqs: summarySeqs.join(", ")
767
1067
  });
768
1068
  if (tierLine !== "") parts.push(tierLine);
1069
+ const tierRules = nudge.tier === 2 ? TIER2_DISTILL_RULES2 : TIER3_CONDENSE_RULES2;
1070
+ parts.push("", tierRules);
1071
+ } else {
1072
+ parts.push(rangeTable(session, prompts));
769
1073
  }
770
- parts.push(rangeTable(session, prompts));
1074
+ if (prompts.nudge.tip !== "") parts.push("", prompts.nudge.tip);
771
1075
  return parts.join("\n");
772
1076
  }
773
1077
 
@@ -811,11 +1115,22 @@ function requireAgent(exec) {
811
1115
  return exec.agent;
812
1116
  }
813
1117
  var compressParameters = {
1118
+ // Tolerated wrapped-arguments form: some models emit
1119
+ // `{ "arguments": "{\"content\": [...]}" }` (double-nested) or
1120
+ // `{ "arguments": { "content": [...] } }` instead of the unwrapped
1121
+ // `{ "content": [...] }`. The old DSH validator surfaced this as
1122
+ // `invalid arguments: "arguments" must be an object` and the model retried
1123
+ // forever. `arguments` is accepted as an optional JSON node so the wrapped
1124
+ // shape passes schema validation; `handleCompress` unwraps it and falls back
1125
+ // to a clear runtime error when neither form carries content. `content` is
1126
+ // intentionally NOT `required: true` — a required property would reject the
1127
+ // wrapped shape before `handleCompress` can see it. The tool description
1128
+ // still tells the model content is mandatory.
1129
+ arguments: { type: "json", description: "Tolerated wrapped-arguments form (model-generated); unwrapped in handleCompress. Prefer passing content directly." },
814
1130
  topic: { type: "string", description: "Fallback topic for entries without their own." },
815
1131
  content: {
816
1132
  type: "array",
817
- required: true,
818
- description: "One or more ranges to compress, each with startSeq/endSeq boundaries (surface seqs) and a dense summary.",
1133
+ description: "One or more ranges to compress, each with startSeq/endSeq boundaries (surface seqs) and a dense summary. Required \u2014 pass it directly, not wrapped in an arguments key.",
819
1134
  items: {
820
1135
  type: "object",
821
1136
  properties: {
@@ -846,9 +1161,26 @@ function parseSeq(value) {
846
1161
  }
847
1162
  return seq;
848
1163
  }
1164
+ function unwrapCompressArgs(args) {
1165
+ if (args.content !== void 0) return args;
1166
+ if (args.arguments === void 0) return null;
1167
+ let inner = args.arguments;
1168
+ if (typeof inner === "string") {
1169
+ try {
1170
+ inner = JSON.parse(inner);
1171
+ } catch {
1172
+ return null;
1173
+ }
1174
+ }
1175
+ if (typeof inner !== "object" || inner === null || Array.isArray(inner)) return null;
1176
+ const content = inner.content;
1177
+ if (content === void 0) return null;
1178
+ return { ...args, content };
1179
+ }
849
1180
  async function handleCompress(env, args, exec) {
850
1181
  const agent = requireAgent(exec);
851
1182
  const session = agent.session;
1183
+ stripOrphanedSurfaceToolMessages(session, openToolCallIds(session));
852
1184
  const state = env.store.stateFor(session);
853
1185
  const coreMessages = allLogMessages(session);
854
1186
  const surfaceMessages = eventsToCoreMessages(surfaceEventsOf(session));
@@ -857,6 +1189,13 @@ async function handleCompress(env, args, exec) {
857
1189
  const turn = env.kernel.processTurn({ messages: coreMessages, state, config, tokenCount });
858
1190
  env.store.set(session, turn.state);
859
1191
  const byRaw = turn.state.messageRefs.byRaw;
1192
+ const unwrapped = unwrapCompressArgs(args);
1193
+ if (unwrapped === null) {
1194
+ return {
1195
+ text: "compress: missing content \u2014 pass the content array directly: compress({ content: [{ startSeq, endSeq, summary }] })"
1196
+ };
1197
+ }
1198
+ args = unwrapped;
860
1199
  const ranges = [];
861
1200
  const alreadyCompressedNotes = [];
862
1201
  for (const range of args.content) {
@@ -912,10 +1251,13 @@ async function handleCompress(env, args, exec) {
912
1251
  // non-block-covered messages as the visible feed, so default behavior is
913
1252
  // preserved. Any 'Excluded N protected message(s)' warning is surfaced.
914
1253
  });
915
- if (applied.result.errors.length > 0) {
1254
+ if (applied.result.errors.length > 0 && applied.result.blocksCreated === 0) {
916
1255
  return { text: `compress failed: ${applied.result.errors.join("; ")}` };
917
1256
  }
918
1257
  env.store.set(session, applied.state);
1258
+ if (applied.result.blocksCreated > 0) {
1259
+ env.compressCallIdsToHide?.add(exec.callId);
1260
+ }
919
1261
  const previousIds = new Set(turn.state.blocks.map((block) => block.blockId));
920
1262
  const newBlocks = applied.state.blocks.filter((block) => !previousIds.has(block.blockId));
921
1263
  const blockByRangeKey = new Map(newBlocks.map((block) => [`${block.startRef}::${block.endRef}`, block]));
@@ -978,8 +1320,9 @@ async function handleCompress(env, args, exec) {
978
1320
  }
979
1321
  const summaryLine = `Compressed ${applied.result.blocksCreated} block(s), ~${applied.result.tokensCompressed} tokens reclaimed.`;
980
1322
  const totalSkipped = skippedRanges + alreadyCompressedNotes.length;
981
- const warningLines = [...freeWarnings.map((warning) => ` ${warning}`), ...alreadyCompressedNotes, ...lines];
982
- const footer = totalSkipped > 0 ? ` (${totalSkipped} range(s) skipped \u2014 see warnings above)` : "";
1323
+ const failedLines = applied.result.errors.map((error) => ` ${error}`);
1324
+ const warningLines = [...freeWarnings.map((warning) => ` ${warning}`), ...failedLines, ...alreadyCompressedNotes, ...lines];
1325
+ const footer = totalSkipped > 0 ? ` (${totalSkipped} range(s) skipped or failed \u2014 see above)` : "";
983
1326
  return { text: `${summaryLine}
984
1327
  ${[...warningLines, footer].filter((line) => line !== "").join("\n")}` };
985
1328
  }
@@ -1213,6 +1556,8 @@ var AcpCompactionEngine = class extends CompactionEngine {
1213
1556
  /** Resolved prompt templates (validated at construction — fail-fast on template typos). */
1214
1557
  prompts;
1215
1558
  lastNudgeTurn = /* @__PURE__ */ new Map();
1559
+ /** Successful compress call ids awaiting their tool/result so the pair can be hidden. */
1560
+ compressCallIdsToHide = /* @__PURE__ */ new Set();
1216
1561
  /** Per provider/model route the resolved window (probe failures cached too). */
1217
1562
  windowCache = /* @__PURE__ */ new Map();
1218
1563
  constructor(ctx, config = {}) {
@@ -1232,7 +1577,8 @@ var AcpCompactionEngine = class extends CompactionEngine {
1232
1577
  nudgeEmergencyThresholdPct: this.config.nudgeEmergencyThresholdPct,
1233
1578
  coreOverrides: this.config.coreOverrides,
1234
1579
  windowFor: (agent) => this.windowFor(agent),
1235
- prompts: this.prompts
1580
+ prompts: this.prompts,
1581
+ compressCallIdsToHide: this.compressCallIdsToHide
1236
1582
  };
1237
1583
  const tools = ctx.get("tools");
1238
1584
  if (tools !== void 0) {
@@ -1266,16 +1612,27 @@ var AcpCompactionEngine = class extends CompactionEngine {
1266
1612
  if (name === "commands") registerCommand();
1267
1613
  });
1268
1614
  }
1269
- if (this.config.autoNudge) {
1270
- ctx.on("agent/pre-step", async (payload, next) => {
1271
- const decision = await next();
1272
- if (decision.kind === "reject") return decision;
1273
- const window = await this.windowFor(payload.agent);
1274
- const outcome = buildNudge(payload.agent, { ...env, modelContextLimit: window.limit }, this.lastNudgeTurn);
1275
- if (outcome === null) return decision;
1276
- return { kind: "enter", messages: [...decision.messages, outcome.message] };
1615
+ ctx.on("session/event", (session, event) => {
1616
+ if (event.type !== "tool/result") return;
1617
+ const message = event.data.message;
1618
+ const block = message.content[0];
1619
+ const callId = block?.toolCallId ?? message.source.callId;
1620
+ if (typeof callId !== "string" || !this.compressCallIdsToHide.has(callId)) return;
1621
+ this.compressCallIdsToHide.delete(callId);
1622
+ deferCompressPairHide(session, callId, event.seq, (error) => {
1623
+ ctx.logger.warn(`billion-context-dsh: hide compress call/result pair failed: ${String(error)}`);
1277
1624
  });
1278
- }
1625
+ });
1626
+ ctx.on("agent/pre-step", async (payload, next) => {
1627
+ stripOrphanedSurfaceToolMessages(payload.agent.session);
1628
+ if (!this.config.autoNudge) return next();
1629
+ const decision = await next();
1630
+ if (decision.kind === "reject") return decision;
1631
+ const window = await this.windowFor(payload.agent);
1632
+ const outcome = buildNudge(payload.agent, { ...env, modelContextLimit: window.limit }, this.lastNudgeTurn);
1633
+ if (outcome === null) return decision;
1634
+ return { kind: "enter", messages: [...decision.messages, outcome.message] };
1635
+ });
1279
1636
  const systemPrompt = ctx.get("systemPrompt");
1280
1637
  if (systemPrompt !== void 0) {
1281
1638
  systemPrompt.section({
@@ -1372,6 +1729,7 @@ export {
1372
1729
  expandShadowedSeqs,
1373
1730
  extractEventText,
1374
1731
  findOpenTurn,
1732
+ hideCompressToolPair,
1375
1733
  kernelConfigFor,
1376
1734
  makeTools,
1377
1735
  projectEvent,
@@ -1384,6 +1742,7 @@ export {
1384
1742
  resolveTokenCount,
1385
1743
  runCompactionTransaction,
1386
1744
  shadowedSeqsOf,
1745
+ stripOrphanedSurfaceToolMessages,
1387
1746
  summarySeqOfKernelBlock,
1388
1747
  surfaceEventsOf,
1389
1748
  windowSourceLabel