@caupulican/pi-agent-core 0.80.103 → 0.81.1

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.
Files changed (148) hide show
  1. package/dist/compaction/branch-summarization.d.ts +88 -0
  2. package/dist/compaction/branch-summarization.d.ts.map +1 -0
  3. package/dist/{harness/compaction → compaction}/branch-summarization.js +86 -18
  4. package/dist/compaction/branch-summarization.js.map +1 -0
  5. package/dist/compaction/compaction.d.ts +143 -0
  6. package/dist/compaction/compaction.d.ts.map +1 -0
  7. package/dist/{harness/compaction → compaction}/compaction.js +208 -77
  8. package/dist/compaction/compaction.js.map +1 -0
  9. package/dist/compaction/index.d.ts +7 -0
  10. package/dist/compaction/index.d.ts.map +1 -0
  11. package/dist/compaction/index.js +7 -0
  12. package/dist/compaction/index.js.map +1 -0
  13. package/dist/compaction/utils.d.ts +38 -0
  14. package/dist/compaction/utils.d.ts.map +1 -0
  15. package/dist/{harness/compaction → compaction}/utils.js +36 -14
  16. package/dist/compaction/utils.js.map +1 -0
  17. package/dist/index.d.ts +5 -15
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -16
  20. package/dist/index.js.map +1 -1
  21. package/dist/{harness/messages.d.ts → messages.d.ts} +28 -2
  22. package/dist/messages.d.ts.map +1 -0
  23. package/dist/{harness/messages.js → messages.js} +22 -1
  24. package/dist/messages.js.map +1 -0
  25. package/dist/node.d.ts +5 -1
  26. package/dist/node.d.ts.map +1 -1
  27. package/dist/node.js +9 -1
  28. package/dist/node.js.map +1 -1
  29. package/dist/reliability/classifier.d.ts +33 -0
  30. package/dist/reliability/classifier.d.ts.map +1 -0
  31. package/dist/reliability/classifier.js +69 -0
  32. package/dist/reliability/classifier.js.map +1 -0
  33. package/dist/reliability/index.d.ts +5 -0
  34. package/dist/reliability/index.d.ts.map +1 -0
  35. package/dist/reliability/index.js +5 -0
  36. package/dist/reliability/index.js.map +1 -0
  37. package/dist/reliability/node.d.ts +2 -0
  38. package/dist/reliability/node.d.ts.map +1 -0
  39. package/dist/reliability/node.js +2 -0
  40. package/dist/reliability/node.js.map +1 -0
  41. package/dist/reliability/process-tree.d.ts +10 -0
  42. package/dist/reliability/process-tree.d.ts.map +1 -0
  43. package/dist/reliability/process-tree.js +75 -0
  44. package/dist/reliability/process-tree.js.map +1 -0
  45. package/dist/reliability/retry-controller.d.ts +66 -0
  46. package/dist/reliability/retry-controller.d.ts.map +1 -0
  47. package/dist/reliability/retry-controller.js +104 -0
  48. package/dist/reliability/retry-controller.js.map +1 -0
  49. package/dist/reliability/retry.d.ts +19 -0
  50. package/dist/reliability/retry.d.ts.map +1 -0
  51. package/dist/reliability/retry.js +35 -0
  52. package/dist/reliability/retry.js.map +1 -0
  53. package/dist/reliability/watchdogs.d.ts +70 -0
  54. package/dist/reliability/watchdogs.d.ts.map +1 -0
  55. package/dist/reliability/watchdogs.js +203 -0
  56. package/dist/reliability/watchdogs.js.map +1 -0
  57. package/dist/session/message-retention.d.ts +26 -0
  58. package/dist/session/message-retention.d.ts.map +1 -0
  59. package/dist/session/message-retention.js +95 -0
  60. package/dist/session/message-retention.js.map +1 -0
  61. package/dist/session/session-manager.d.ts +350 -0
  62. package/dist/session/session-manager.d.ts.map +1 -0
  63. package/dist/session/session-manager.js +1339 -0
  64. package/dist/session/session-manager.js.map +1 -0
  65. package/dist/utils/paths.d.ts +15 -0
  66. package/dist/utils/paths.d.ts.map +1 -0
  67. package/dist/utils/paths.js +31 -0
  68. package/dist/utils/paths.js.map +1 -0
  69. package/dist/utils/shell-output.d.ts +10 -0
  70. package/dist/utils/shell-output.d.ts.map +1 -0
  71. package/dist/utils/shell-output.js +38 -0
  72. package/dist/utils/shell-output.js.map +1 -0
  73. package/dist/utils/truncate.d.ts.map +1 -0
  74. package/dist/{harness/utils → utils}/truncate.js +27 -102
  75. package/dist/utils/truncate.js.map +1 -0
  76. package/dist/uuid.d.ts.map +1 -0
  77. package/dist/uuid.js.map +1 -0
  78. package/package.json +6 -2
  79. package/dist/harness/agent-harness.d.ts +0 -95
  80. package/dist/harness/agent-harness.d.ts.map +0 -1
  81. package/dist/harness/agent-harness.js +0 -963
  82. package/dist/harness/agent-harness.js.map +0 -1
  83. package/dist/harness/compaction/branch-summarization.d.ts +0 -53
  84. package/dist/harness/compaction/branch-summarization.d.ts.map +0 -1
  85. package/dist/harness/compaction/branch-summarization.js.map +0 -1
  86. package/dist/harness/compaction/compaction.d.ts +0 -95
  87. package/dist/harness/compaction/compaction.d.ts.map +0 -1
  88. package/dist/harness/compaction/compaction.js.map +0 -1
  89. package/dist/harness/compaction/utils.d.ts +0 -25
  90. package/dist/harness/compaction/utils.d.ts.map +0 -1
  91. package/dist/harness/compaction/utils.js.map +0 -1
  92. package/dist/harness/env/nodejs.d.ts +0 -51
  93. package/dist/harness/env/nodejs.d.ts.map +0 -1
  94. package/dist/harness/env/nodejs.js +0 -486
  95. package/dist/harness/env/nodejs.js.map +0 -1
  96. package/dist/harness/messages.d.ts.map +0 -1
  97. package/dist/harness/messages.js.map +0 -1
  98. package/dist/harness/prompt-templates.d.ts +0 -48
  99. package/dist/harness/prompt-templates.d.ts.map +0 -1
  100. package/dist/harness/prompt-templates.js +0 -233
  101. package/dist/harness/prompt-templates.js.map +0 -1
  102. package/dist/harness/session/jsonl-repo.d.ts +0 -28
  103. package/dist/harness/session/jsonl-repo.d.ts.map +0 -1
  104. package/dist/harness/session/jsonl-repo.js +0 -112
  105. package/dist/harness/session/jsonl-repo.js.map +0 -1
  106. package/dist/harness/session/jsonl-storage.d.ts +0 -33
  107. package/dist/harness/session/jsonl-storage.d.ts.map +0 -1
  108. package/dist/harness/session/jsonl-storage.js +0 -231
  109. package/dist/harness/session/jsonl-storage.js.map +0 -1
  110. package/dist/harness/session/memory-repo.d.ts +0 -18
  111. package/dist/harness/session/memory-repo.d.ts.map +0 -1
  112. package/dist/harness/session/memory-repo.js +0 -42
  113. package/dist/harness/session/memory-repo.js.map +0 -1
  114. package/dist/harness/session/memory-storage.d.ts +0 -25
  115. package/dist/harness/session/memory-storage.d.ts.map +0 -1
  116. package/dist/harness/session/memory-storage.js +0 -114
  117. package/dist/harness/session/memory-storage.js.map +0 -1
  118. package/dist/harness/session/repo-utils.d.ts +0 -11
  119. package/dist/harness/session/repo-utils.d.ts.map +0 -1
  120. package/dist/harness/session/repo-utils.js +0 -39
  121. package/dist/harness/session/repo-utils.js.map +0 -1
  122. package/dist/harness/session/session.d.ts +0 -33
  123. package/dist/harness/session/session.d.ts.map +0 -1
  124. package/dist/harness/session/session.js +0 -210
  125. package/dist/harness/session/session.js.map +0 -1
  126. package/dist/harness/session/uuid.d.ts.map +0 -1
  127. package/dist/harness/session/uuid.js.map +0 -1
  128. package/dist/harness/skills.d.ts +0 -44
  129. package/dist/harness/skills.d.ts.map +0 -1
  130. package/dist/harness/skills.js +0 -311
  131. package/dist/harness/skills.js.map +0 -1
  132. package/dist/harness/system-prompt.d.ts +0 -3
  133. package/dist/harness/system-prompt.d.ts.map +0 -1
  134. package/dist/harness/system-prompt.js +0 -30
  135. package/dist/harness/system-prompt.js.map +0 -1
  136. package/dist/harness/types.d.ts +0 -615
  137. package/dist/harness/types.d.ts.map +0 -1
  138. package/dist/harness/types.js +0 -94
  139. package/dist/harness/types.js.map +0 -1
  140. package/dist/harness/utils/shell-output.d.ts +0 -14
  141. package/dist/harness/utils/shell-output.d.ts.map +0 -1
  142. package/dist/harness/utils/shell-output.js +0 -126
  143. package/dist/harness/utils/shell-output.js.map +0 -1
  144. package/dist/harness/utils/truncate.d.ts.map +0 -1
  145. package/dist/harness/utils/truncate.js.map +0 -1
  146. /package/dist/{harness/utils → utils}/truncate.d.ts +0 -0
  147. /package/dist/{harness/session/uuid.d.ts → uuid.d.ts} +0 -0
  148. /package/dist/{harness/session/uuid.js → uuid.js} +0 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Silence/idle watchdogs for the reliability kernel.
3
+ *
4
+ * A silence watchdog bounds "running but mute" — it never bounds total runtime,
5
+ * so long tasks that produce output are never killed (autonomy constraint).
6
+ */
7
+ export interface SilenceWatchdog {
8
+ /** Report activity (output chunk / stream event); resets the countdown.
9
+ * Pass silenceMs to also change the bound for this and subsequent countdowns. */
10
+ touch(silenceMs?: number): void;
11
+ /** Stop permanently (normal completion). Idempotent. */
12
+ disarm(): void;
13
+ }
14
+ export interface SilenceWatchdogOptions {
15
+ silenceMs: number;
16
+ /** Fired at most once, after silenceMs with no touch(). The watchdog self-disarms. */
17
+ onSilence: () => void;
18
+ }
19
+ export declare function createSilenceWatchdog(opts: SilenceWatchdogOptions): SilenceWatchdog;
20
+ import type { StreamFn } from "../types.ts";
21
+ export type StallPhase = "connect" | "quiet" | "active";
22
+ export interface StreamIdleOptions {
23
+ /** Max ms to wait for the FIRST event (connection/first-token allowance). */
24
+ connectMs: number;
25
+ /** Max ms between events while content is flowing — the latest content block is
26
+ * text or toolCall. A flowing stream that goes silent this long is presumed dead. */
27
+ activeIdleMs: number;
28
+ /** Max ms between events while the model is quietly working — no content blocks
29
+ * yet (provider queue / prompt prefill / unstreamed reasoning) or the latest block
30
+ * is thinking. Deep-thinking models and huge compaction prompts legitimately sit
31
+ * here for minutes, so this bound is deliberately generous. */
32
+ quietIdleMs: number;
33
+ /** Fired once when a stall is detected, before the inner request is aborted. */
34
+ onStall?: (info: {
35
+ phase: StallPhase;
36
+ elapsedMs: number;
37
+ }) => void;
38
+ }
39
+ /** User-locked defaults: connect 120s / active 180s / quiet 600s. The quiet bound must stay
40
+ * below the HTTP dispatcher idle timeout (see coding-agent http-dispatcher.ts, 660s) or the
41
+ * HTTP layer would kill quiet-but-healthy streams before this watchdog ever sees the gap. */
42
+ export declare const DEFAULT_STREAM_IDLE: StreamIdleOptions;
43
+ /** Re-resolved at the start of every request, so hosts can wire live-tunable settings. */
44
+ export type StreamIdleOptionsResolver = () => Partial<StreamIdleOptions>;
45
+ /**
46
+ * Wrap a StreamFn so a silently dead connection cannot wedge a turn forever.
47
+ *
48
+ * Phase-aware: `connectMs` bounds the wait for the first event; after that the
49
+ * inter-event bound adapts to what the stream is doing — `quietIdleMs` while the
50
+ * model is quietly working (no content blocks yet, or the latest block is thinking:
51
+ * prefill, provider queues, unstreamed reasoning) and `activeIdleMs` once content is
52
+ * flowing (latest block is text/toolCall). This keeps detection fast where silence is
53
+ * anomalous without killing healthy deep-thinking or compaction-sized requests.
54
+ * No bound ever limits total runtime (autonomy constraint).
55
+ *
56
+ * On stall, the inner request is aborted and the returned stream resolves immediately
57
+ * with a synthetic `AssistantMessage` (`stopReason: "error"`, `errorMessage: "stream
58
+ * stalled: no events for <n>ms (<phase> phase)"`) — the `stream stalled` phrasing is
59
+ * what `classifyFailure` maps to a retryable `stream_stall`, so the host's
60
+ * retry/failover path takes it from there.
61
+ *
62
+ * Options may be a resolver function; it is re-invoked at the start of every request,
63
+ * so settings changes apply without rewrapping.
64
+ *
65
+ * A caller-initiated abort (via the options `signal`) is never treated as a stall: it
66
+ * is chained into the wrapper's own controller and the inner stream's own abort result
67
+ * is forwarded untouched.
68
+ */
69
+ export declare function withStreamIdleWatchdog(streamFn: StreamFn, options?: Partial<StreamIdleOptions> | StreamIdleOptionsResolver): StreamFn;
70
+ //# sourceMappingURL=watchdogs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watchdogs.d.ts","sourceRoot":"","sources":["../../src/reliability/watchdogs.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,eAAe;IAC/B;sFACkF;IAClF,KAAK,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,wDAAwD;IACxD,MAAM,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,sBAAsB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,sFAAsF;IACtF,SAAS,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,sBAAsB,GAAG,eAAe,CA8BnF;AASD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,CAAC;AAExD,MAAM,WAAW,iBAAiB;IACjC,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC;IAClB;0FACsF;IACtF,YAAY,EAAE,MAAM,CAAC;IACrB;;;oEAGgE;IAChE,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,UAAU,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACnE;AAED;;8FAE8F;AAC9F,eAAO,MAAM,mBAAmB,EAAE,iBAIjC,CAAC;AAEF,0FAA0F;AAC1F,MAAM,MAAM,yBAAyB,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC;AASzE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,sBAAsB,CACrC,QAAQ,EAAE,QAAQ,EAClB,OAAO,CAAC,EAAE,OAAO,CAAC,iBAAiB,CAAC,GAAG,yBAAyB,GAC9D,QAAQ,CA0HV","sourcesContent":["/**\n * Silence/idle watchdogs for the reliability kernel.\n *\n * A silence watchdog bounds \"running but mute\" — it never bounds total runtime,\n * so long tasks that produce output are never killed (autonomy constraint).\n */\n\nexport interface SilenceWatchdog {\n\t/** Report activity (output chunk / stream event); resets the countdown.\n\t * Pass silenceMs to also change the bound for this and subsequent countdowns. */\n\ttouch(silenceMs?: number): void;\n\t/** Stop permanently (normal completion). Idempotent. */\n\tdisarm(): void;\n}\n\nexport interface SilenceWatchdogOptions {\n\tsilenceMs: number;\n\t/** Fired at most once, after silenceMs with no touch(). The watchdog self-disarms. */\n\tonSilence: () => void;\n}\n\nexport function createSilenceWatchdog(opts: SilenceWatchdogOptions): SilenceWatchdog {\n\tlet timer: NodeJS.Timeout | undefined;\n\tlet disarmed = false;\n\tlet currentSilenceMs = opts.silenceMs;\n\n\tconst arm = () => {\n\t\ttimer = setTimeout(() => {\n\t\t\tdisarmed = true;\n\t\t\ttimer = undefined;\n\t\t\topts.onSilence();\n\t\t}, currentSilenceMs);\n\t\t// Never keep the host process alive just for a watchdog.\n\t\ttimer.unref?.();\n\t};\n\n\tarm();\n\n\treturn {\n\t\ttouch(silenceMs?: number): void {\n\t\t\tif (disarmed) return;\n\t\t\tif (silenceMs !== undefined) currentSilenceMs = silenceMs;\n\t\t\tif (timer) clearTimeout(timer);\n\t\t\tarm();\n\t\t},\n\t\tdisarm(): void {\n\t\t\tdisarmed = true;\n\t\t\tif (timer) clearTimeout(timer);\n\t\t\ttimer = undefined;\n\t\t},\n\t};\n}\n\n// --- Stream-idle watchdog (wraps a StreamFn) -------------------------------\n\nimport {\n\ttype AssistantMessage,\n\ttype AssistantMessageEvent,\n\tcreateAssistantMessageEventStream,\n} from \"@caupulican/pi-ai\";\nimport type { StreamFn } from \"../types.ts\";\n\nexport type StallPhase = \"connect\" | \"quiet\" | \"active\";\n\nexport interface StreamIdleOptions {\n\t/** Max ms to wait for the FIRST event (connection/first-token allowance). */\n\tconnectMs: number;\n\t/** Max ms between events while content is flowing — the latest content block is\n\t * text or toolCall. A flowing stream that goes silent this long is presumed dead. */\n\tactiveIdleMs: number;\n\t/** Max ms between events while the model is quietly working — no content blocks\n\t * yet (provider queue / prompt prefill / unstreamed reasoning) or the latest block\n\t * is thinking. Deep-thinking models and huge compaction prompts legitimately sit\n\t * here for minutes, so this bound is deliberately generous. */\n\tquietIdleMs: number;\n\t/** Fired once when a stall is detected, before the inner request is aborted. */\n\tonStall?: (info: { phase: StallPhase; elapsedMs: number }) => void;\n}\n\n/** User-locked defaults: connect 120s / active 180s / quiet 600s. The quiet bound must stay\n * below the HTTP dispatcher idle timeout (see coding-agent http-dispatcher.ts, 660s) or the\n * HTTP layer would kill quiet-but-healthy streams before this watchdog ever sees the gap. */\nexport const DEFAULT_STREAM_IDLE: StreamIdleOptions = {\n\tconnectMs: 120_000,\n\tactiveIdleMs: 180_000,\n\tquietIdleMs: 600_000,\n};\n\n/** Re-resolved at the start of every request, so hosts can wire live-tunable settings. */\nexport type StreamIdleOptionsResolver = () => Partial<StreamIdleOptions>;\n\n/** Extracts the current AssistantMessage snapshot carried by any stream event variant. */\nfunction partialFromEvent(event: AssistantMessageEvent): AssistantMessage {\n\tif (event.type === \"done\") return event.message;\n\tif (event.type === \"error\") return event.error;\n\treturn event.partial;\n}\n\n/**\n * Wrap a StreamFn so a silently dead connection cannot wedge a turn forever.\n *\n * Phase-aware: `connectMs` bounds the wait for the first event; after that the\n * inter-event bound adapts to what the stream is doing — `quietIdleMs` while the\n * model is quietly working (no content blocks yet, or the latest block is thinking:\n * prefill, provider queues, unstreamed reasoning) and `activeIdleMs` once content is\n * flowing (latest block is text/toolCall). This keeps detection fast where silence is\n * anomalous without killing healthy deep-thinking or compaction-sized requests.\n * No bound ever limits total runtime (autonomy constraint).\n *\n * On stall, the inner request is aborted and the returned stream resolves immediately\n * with a synthetic `AssistantMessage` (`stopReason: \"error\"`, `errorMessage: \"stream\n * stalled: no events for <n>ms (<phase> phase)\"`) — the `stream stalled` phrasing is\n * what `classifyFailure` maps to a retryable `stream_stall`, so the host's\n * retry/failover path takes it from there.\n *\n * Options may be a resolver function; it is re-invoked at the start of every request,\n * so settings changes apply without rewrapping.\n *\n * A caller-initiated abort (via the options `signal`) is never treated as a stall: it\n * is chained into the wrapper's own controller and the inner stream's own abort result\n * is forwarded untouched.\n */\nexport function withStreamIdleWatchdog(\n\tstreamFn: StreamFn,\n\toptions?: Partial<StreamIdleOptions> | StreamIdleOptionsResolver,\n): StreamFn {\n\treturn async (model, context, streamOptions) => {\n\t\tconst resolved = typeof options === \"function\" ? options() : options;\n\t\tconst cleaned: Partial<StreamIdleOptions> = {};\n\t\tif (resolved) {\n\t\t\tfor (const [key, val] of Object.entries(resolved)) {\n\t\t\t\tif (val !== undefined) {\n\t\t\t\t\t(cleaned as any)[key] = val;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst opts = { ...DEFAULT_STREAM_IDLE, ...cleaned };\n\n\t\tconst controller = new AbortController();\n\t\tconst callerSignal = streamOptions?.signal;\n\t\tlet callerAborted = callerSignal?.aborted ?? false;\n\t\tconst onCallerAbort = () => {\n\t\t\tcallerAborted = true;\n\t\t\tcontroller.abort(callerSignal?.reason);\n\t\t};\n\t\tif (callerAborted) controller.abort(callerSignal?.reason);\n\t\telse callerSignal?.addEventListener(\"abort\", onCallerAbort, { once: true });\n\n\t\tconst inner = await streamFn(model, context, { ...streamOptions, signal: controller.signal });\n\t\tconst outer = createAssistantMessageEventStream();\n\n\t\t// Seeded so a connect-phase stall (no event ever arrived) still has a base message\n\t\t// to report on; overwritten with the latest real snapshot once events start flowing.\n\t\tlet latest: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n\t\t\tapi: model.api,\n\t\t\tprovider: model.provider,\n\t\t\tmodel: model.id,\n\t\t\tusage: {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t},\n\t\t\tstopReason: \"stop\",\n\t\t\ttimestamp: Date.now(),\n\t\t};\n\t\tlet stalled = false;\n\t\tlet firstEventSeen = false;\n\n\t\t// The idle bound adapts per event: quiet while nothing/thinking, active while\n\t\t// text/toolCall content is flowing. Mutable so the onSilence closure always\n\t\t// reports the phase/bound that actually elapsed.\n\t\tlet currentPhase: StallPhase = \"connect\";\n\t\tlet currentBoundMs = opts.connectMs;\n\t\tconst idleBoundFor = (message: AssistantMessage): { phase: StallPhase; ms: number } => {\n\t\t\tconst lastBlock = message.content[message.content.length - 1];\n\t\t\treturn !lastBlock || lastBlock.type === \"thinking\"\n\t\t\t\t? { phase: \"quiet\", ms: opts.quietIdleMs }\n\t\t\t\t: { phase: \"active\", ms: opts.activeIdleMs };\n\t\t};\n\n\t\t// Emits the stall result directly (rather than after the inner loop finishes) so a\n\t\t// connection that never resolves at all still yields a result promptly — providers\n\t\t// are contractually expected to end their stream after abort, but the watchdog does\n\t\t// not depend on that to report the stall itself.\n\t\tconst stall = (phase: StallPhase, elapsedMs: number) => {\n\t\t\tif (callerAborted || stalled) return;\n\t\t\tstalled = true;\n\t\t\topts.onStall?.({ phase, elapsedMs });\n\t\t\tconst description = `stream stalled: no events for ${elapsedMs}ms (${phase} phase)`;\n\t\t\tcontroller.abort(new Error(description));\n\t\t\tconst message: AssistantMessage = {\n\t\t\t\t...latest,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terrorMessage: description,\n\t\t\t};\n\t\t\touter.push({ type: \"error\", reason: \"error\", error: message });\n\t\t};\n\n\t\tlet watchdog = createSilenceWatchdog({\n\t\t\tsilenceMs: opts.connectMs,\n\t\t\tonSilence: () => stall(currentPhase, currentBoundMs),\n\t\t});\n\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\tfor await (const event of inner) {\n\t\t\t\t\tif (stalled) break;\n\t\t\t\t\tlatest = partialFromEvent(event);\n\t\t\t\t\tconst bound = idleBoundFor(latest);\n\t\t\t\t\tcurrentPhase = bound.phase;\n\t\t\t\t\tcurrentBoundMs = bound.ms;\n\t\t\t\t\tif (!firstEventSeen) {\n\t\t\t\t\t\tfirstEventSeen = true;\n\t\t\t\t\t\twatchdog.disarm();\n\t\t\t\t\t\twatchdog = createSilenceWatchdog({\n\t\t\t\t\t\t\tsilenceMs: bound.ms,\n\t\t\t\t\t\t\tonSilence: () => stall(currentPhase, currentBoundMs),\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\twatchdog.touch(bound.ms);\n\t\t\t\t\t}\n\t\t\t\t\t// A terminal event ends the turn: disarm synchronously, in the same tick as\n\t\t\t\t\t// the push below, so no watchdog can fire after the consumer's `result()`\n\t\t\t\t\t// promise resolves — a disarm that only happened once the loop later notices\n\t\t\t\t\t// `inner` is done would race with that resolution (it runs a tick or more\n\t\t\t\t\t// later) and could fire a spurious stall on an already-finished stream.\n\t\t\t\t\tconst terminal = event.type === \"done\" || event.type === \"error\";\n\t\t\t\t\tif (terminal) {\n\t\t\t\t\t\twatchdog.disarm();\n\t\t\t\t\t\tcallerSignal?.removeEventListener(\"abort\", onCallerAbort);\n\t\t\t\t\t}\n\t\t\t\t\touter.push(event);\n\t\t\t\t\tif (terminal) return;\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\twatchdog.disarm();\n\t\t\t\tcallerSignal?.removeEventListener(\"abort\", onCallerAbort);\n\t\t\t}\n\t\t})();\n\n\t\treturn outer;\n\t};\n}\n"]}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Silence/idle watchdogs for the reliability kernel.
3
+ *
4
+ * A silence watchdog bounds "running but mute" — it never bounds total runtime,
5
+ * so long tasks that produce output are never killed (autonomy constraint).
6
+ */
7
+ export function createSilenceWatchdog(opts) {
8
+ let timer;
9
+ let disarmed = false;
10
+ let currentSilenceMs = opts.silenceMs;
11
+ const arm = () => {
12
+ timer = setTimeout(() => {
13
+ disarmed = true;
14
+ timer = undefined;
15
+ opts.onSilence();
16
+ }, currentSilenceMs);
17
+ // Never keep the host process alive just for a watchdog.
18
+ timer.unref?.();
19
+ };
20
+ arm();
21
+ return {
22
+ touch(silenceMs) {
23
+ if (disarmed)
24
+ return;
25
+ if (silenceMs !== undefined)
26
+ currentSilenceMs = silenceMs;
27
+ if (timer)
28
+ clearTimeout(timer);
29
+ arm();
30
+ },
31
+ disarm() {
32
+ disarmed = true;
33
+ if (timer)
34
+ clearTimeout(timer);
35
+ timer = undefined;
36
+ },
37
+ };
38
+ }
39
+ // --- Stream-idle watchdog (wraps a StreamFn) -------------------------------
40
+ import { createAssistantMessageEventStream, } from "@caupulican/pi-ai";
41
+ /** User-locked defaults: connect 120s / active 180s / quiet 600s. The quiet bound must stay
42
+ * below the HTTP dispatcher idle timeout (see coding-agent http-dispatcher.ts, 660s) or the
43
+ * HTTP layer would kill quiet-but-healthy streams before this watchdog ever sees the gap. */
44
+ export const DEFAULT_STREAM_IDLE = {
45
+ connectMs: 120_000,
46
+ activeIdleMs: 180_000,
47
+ quietIdleMs: 600_000,
48
+ };
49
+ /** Extracts the current AssistantMessage snapshot carried by any stream event variant. */
50
+ function partialFromEvent(event) {
51
+ if (event.type === "done")
52
+ return event.message;
53
+ if (event.type === "error")
54
+ return event.error;
55
+ return event.partial;
56
+ }
57
+ /**
58
+ * Wrap a StreamFn so a silently dead connection cannot wedge a turn forever.
59
+ *
60
+ * Phase-aware: `connectMs` bounds the wait for the first event; after that the
61
+ * inter-event bound adapts to what the stream is doing — `quietIdleMs` while the
62
+ * model is quietly working (no content blocks yet, or the latest block is thinking:
63
+ * prefill, provider queues, unstreamed reasoning) and `activeIdleMs` once content is
64
+ * flowing (latest block is text/toolCall). This keeps detection fast where silence is
65
+ * anomalous without killing healthy deep-thinking or compaction-sized requests.
66
+ * No bound ever limits total runtime (autonomy constraint).
67
+ *
68
+ * On stall, the inner request is aborted and the returned stream resolves immediately
69
+ * with a synthetic `AssistantMessage` (`stopReason: "error"`, `errorMessage: "stream
70
+ * stalled: no events for <n>ms (<phase> phase)"`) — the `stream stalled` phrasing is
71
+ * what `classifyFailure` maps to a retryable `stream_stall`, so the host's
72
+ * retry/failover path takes it from there.
73
+ *
74
+ * Options may be a resolver function; it is re-invoked at the start of every request,
75
+ * so settings changes apply without rewrapping.
76
+ *
77
+ * A caller-initiated abort (via the options `signal`) is never treated as a stall: it
78
+ * is chained into the wrapper's own controller and the inner stream's own abort result
79
+ * is forwarded untouched.
80
+ */
81
+ export function withStreamIdleWatchdog(streamFn, options) {
82
+ return async (model, context, streamOptions) => {
83
+ const resolved = typeof options === "function" ? options() : options;
84
+ const cleaned = {};
85
+ if (resolved) {
86
+ for (const [key, val] of Object.entries(resolved)) {
87
+ if (val !== undefined) {
88
+ cleaned[key] = val;
89
+ }
90
+ }
91
+ }
92
+ const opts = { ...DEFAULT_STREAM_IDLE, ...cleaned };
93
+ const controller = new AbortController();
94
+ const callerSignal = streamOptions?.signal;
95
+ let callerAborted = callerSignal?.aborted ?? false;
96
+ const onCallerAbort = () => {
97
+ callerAborted = true;
98
+ controller.abort(callerSignal?.reason);
99
+ };
100
+ if (callerAborted)
101
+ controller.abort(callerSignal?.reason);
102
+ else
103
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
104
+ const inner = await streamFn(model, context, { ...streamOptions, signal: controller.signal });
105
+ const outer = createAssistantMessageEventStream();
106
+ // Seeded so a connect-phase stall (no event ever arrived) still has a base message
107
+ // to report on; overwritten with the latest real snapshot once events start flowing.
108
+ let latest = {
109
+ role: "assistant",
110
+ content: [],
111
+ api: model.api,
112
+ provider: model.provider,
113
+ model: model.id,
114
+ usage: {
115
+ input: 0,
116
+ output: 0,
117
+ cacheRead: 0,
118
+ cacheWrite: 0,
119
+ totalTokens: 0,
120
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
121
+ },
122
+ stopReason: "stop",
123
+ timestamp: Date.now(),
124
+ };
125
+ let stalled = false;
126
+ let firstEventSeen = false;
127
+ // The idle bound adapts per event: quiet while nothing/thinking, active while
128
+ // text/toolCall content is flowing. Mutable so the onSilence closure always
129
+ // reports the phase/bound that actually elapsed.
130
+ let currentPhase = "connect";
131
+ let currentBoundMs = opts.connectMs;
132
+ const idleBoundFor = (message) => {
133
+ const lastBlock = message.content[message.content.length - 1];
134
+ return !lastBlock || lastBlock.type === "thinking"
135
+ ? { phase: "quiet", ms: opts.quietIdleMs }
136
+ : { phase: "active", ms: opts.activeIdleMs };
137
+ };
138
+ // Emits the stall result directly (rather than after the inner loop finishes) so a
139
+ // connection that never resolves at all still yields a result promptly — providers
140
+ // are contractually expected to end their stream after abort, but the watchdog does
141
+ // not depend on that to report the stall itself.
142
+ const stall = (phase, elapsedMs) => {
143
+ if (callerAborted || stalled)
144
+ return;
145
+ stalled = true;
146
+ opts.onStall?.({ phase, elapsedMs });
147
+ const description = `stream stalled: no events for ${elapsedMs}ms (${phase} phase)`;
148
+ controller.abort(new Error(description));
149
+ const message = {
150
+ ...latest,
151
+ stopReason: "error",
152
+ errorMessage: description,
153
+ };
154
+ outer.push({ type: "error", reason: "error", error: message });
155
+ };
156
+ let watchdog = createSilenceWatchdog({
157
+ silenceMs: opts.connectMs,
158
+ onSilence: () => stall(currentPhase, currentBoundMs),
159
+ });
160
+ void (async () => {
161
+ try {
162
+ for await (const event of inner) {
163
+ if (stalled)
164
+ break;
165
+ latest = partialFromEvent(event);
166
+ const bound = idleBoundFor(latest);
167
+ currentPhase = bound.phase;
168
+ currentBoundMs = bound.ms;
169
+ if (!firstEventSeen) {
170
+ firstEventSeen = true;
171
+ watchdog.disarm();
172
+ watchdog = createSilenceWatchdog({
173
+ silenceMs: bound.ms,
174
+ onSilence: () => stall(currentPhase, currentBoundMs),
175
+ });
176
+ }
177
+ else {
178
+ watchdog.touch(bound.ms);
179
+ }
180
+ // A terminal event ends the turn: disarm synchronously, in the same tick as
181
+ // the push below, so no watchdog can fire after the consumer's `result()`
182
+ // promise resolves — a disarm that only happened once the loop later notices
183
+ // `inner` is done would race with that resolution (it runs a tick or more
184
+ // later) and could fire a spurious stall on an already-finished stream.
185
+ const terminal = event.type === "done" || event.type === "error";
186
+ if (terminal) {
187
+ watchdog.disarm();
188
+ callerSignal?.removeEventListener("abort", onCallerAbort);
189
+ }
190
+ outer.push(event);
191
+ if (terminal)
192
+ return;
193
+ }
194
+ }
195
+ finally {
196
+ watchdog.disarm();
197
+ callerSignal?.removeEventListener("abort", onCallerAbort);
198
+ }
199
+ })();
200
+ return outer;
201
+ };
202
+ }
203
+ //# sourceMappingURL=watchdogs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watchdogs.js","sourceRoot":"","sources":["../../src/reliability/watchdogs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Silence/idle watchdogs for the reliability kernel.\n *\n * A silence watchdog bounds \"running but mute\" — it never bounds total runtime,\n * so long tasks that produce output are never killed (autonomy constraint).\n */\n\nexport interface SilenceWatchdog {\n\t/** Report activity (output chunk / stream event); resets the countdown.\n\t * Pass silenceMs to also change the bound for this and subsequent countdowns. */\n\ttouch(silenceMs?: number): void;\n\t/** Stop permanently (normal completion). Idempotent. */\n\tdisarm(): void;\n}\n\nexport interface SilenceWatchdogOptions {\n\tsilenceMs: number;\n\t/** Fired at most once, after silenceMs with no touch(). The watchdog self-disarms. */\n\tonSilence: () => void;\n}\n\nexport function createSilenceWatchdog(opts: SilenceWatchdogOptions): SilenceWatchdog {\n\tlet timer: NodeJS.Timeout | undefined;\n\tlet disarmed = false;\n\tlet currentSilenceMs = opts.silenceMs;\n\n\tconst arm = () => {\n\t\ttimer = setTimeout(() => {\n\t\t\tdisarmed = true;\n\t\t\ttimer = undefined;\n\t\t\topts.onSilence();\n\t\t}, currentSilenceMs);\n\t\t// Never keep the host process alive just for a watchdog.\n\t\ttimer.unref?.();\n\t};\n\n\tarm();\n\n\treturn {\n\t\ttouch(silenceMs?: number): void {\n\t\t\tif (disarmed) return;\n\t\t\tif (silenceMs !== undefined) currentSilenceMs = silenceMs;\n\t\t\tif (timer) clearTimeout(timer);\n\t\t\tarm();\n\t\t},\n\t\tdisarm(): void {\n\t\t\tdisarmed = true;\n\t\t\tif (timer) clearTimeout(timer);\n\t\t\ttimer = undefined;\n\t\t},\n\t};\n}\n\n// --- Stream-idle watchdog (wraps a StreamFn) -------------------------------\n\nimport {\n\ttype AssistantMessage,\n\ttype AssistantMessageEvent,\n\tcreateAssistantMessageEventStream,\n} from \"@caupulican/pi-ai\";\nimport type { StreamFn } from \"../types.ts\";\n\nexport type StallPhase = \"connect\" | \"quiet\" | \"active\";\n\nexport interface StreamIdleOptions {\n\t/** Max ms to wait for the FIRST event (connection/first-token allowance). */\n\tconnectMs: number;\n\t/** Max ms between events while content is flowing — the latest content block is\n\t * text or toolCall. A flowing stream that goes silent this long is presumed dead. */\n\tactiveIdleMs: number;\n\t/** Max ms between events while the model is quietly working — no content blocks\n\t * yet (provider queue / prompt prefill / unstreamed reasoning) or the latest block\n\t * is thinking. Deep-thinking models and huge compaction prompts legitimately sit\n\t * here for minutes, so this bound is deliberately generous. */\n\tquietIdleMs: number;\n\t/** Fired once when a stall is detected, before the inner request is aborted. */\n\tonStall?: (info: { phase: StallPhase; elapsedMs: number }) => void;\n}\n\n/** User-locked defaults: connect 120s / active 180s / quiet 600s. The quiet bound must stay\n * below the HTTP dispatcher idle timeout (see coding-agent http-dispatcher.ts, 660s) or the\n * HTTP layer would kill quiet-but-healthy streams before this watchdog ever sees the gap. */\nexport const DEFAULT_STREAM_IDLE: StreamIdleOptions = {\n\tconnectMs: 120_000,\n\tactiveIdleMs: 180_000,\n\tquietIdleMs: 600_000,\n};\n\n/** Re-resolved at the start of every request, so hosts can wire live-tunable settings. */\nexport type StreamIdleOptionsResolver = () => Partial<StreamIdleOptions>;\n\n/** Extracts the current AssistantMessage snapshot carried by any stream event variant. */\nfunction partialFromEvent(event: AssistantMessageEvent): AssistantMessage {\n\tif (event.type === \"done\") return event.message;\n\tif (event.type === \"error\") return event.error;\n\treturn event.partial;\n}\n\n/**\n * Wrap a StreamFn so a silently dead connection cannot wedge a turn forever.\n *\n * Phase-aware: `connectMs` bounds the wait for the first event; after that the\n * inter-event bound adapts to what the stream is doing — `quietIdleMs` while the\n * model is quietly working (no content blocks yet, or the latest block is thinking:\n * prefill, provider queues, unstreamed reasoning) and `activeIdleMs` once content is\n * flowing (latest block is text/toolCall). This keeps detection fast where silence is\n * anomalous without killing healthy deep-thinking or compaction-sized requests.\n * No bound ever limits total runtime (autonomy constraint).\n *\n * On stall, the inner request is aborted and the returned stream resolves immediately\n * with a synthetic `AssistantMessage` (`stopReason: \"error\"`, `errorMessage: \"stream\n * stalled: no events for <n>ms (<phase> phase)\"`) — the `stream stalled` phrasing is\n * what `classifyFailure` maps to a retryable `stream_stall`, so the host's\n * retry/failover path takes it from there.\n *\n * Options may be a resolver function; it is re-invoked at the start of every request,\n * so settings changes apply without rewrapping.\n *\n * A caller-initiated abort (via the options `signal`) is never treated as a stall: it\n * is chained into the wrapper's own controller and the inner stream's own abort result\n * is forwarded untouched.\n */\nexport function withStreamIdleWatchdog(\n\tstreamFn: StreamFn,\n\toptions?: Partial<StreamIdleOptions> | StreamIdleOptionsResolver,\n): StreamFn {\n\treturn async (model, context, streamOptions) => {\n\t\tconst resolved = typeof options === \"function\" ? options() : options;\n\t\tconst cleaned: Partial<StreamIdleOptions> = {};\n\t\tif (resolved) {\n\t\t\tfor (const [key, val] of Object.entries(resolved)) {\n\t\t\t\tif (val !== undefined) {\n\t\t\t\t\t(cleaned as any)[key] = val;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst opts = { ...DEFAULT_STREAM_IDLE, ...cleaned };\n\n\t\tconst controller = new AbortController();\n\t\tconst callerSignal = streamOptions?.signal;\n\t\tlet callerAborted = callerSignal?.aborted ?? false;\n\t\tconst onCallerAbort = () => {\n\t\t\tcallerAborted = true;\n\t\t\tcontroller.abort(callerSignal?.reason);\n\t\t};\n\t\tif (callerAborted) controller.abort(callerSignal?.reason);\n\t\telse callerSignal?.addEventListener(\"abort\", onCallerAbort, { once: true });\n\n\t\tconst inner = await streamFn(model, context, { ...streamOptions, signal: controller.signal });\n\t\tconst outer = createAssistantMessageEventStream();\n\n\t\t// Seeded so a connect-phase stall (no event ever arrived) still has a base message\n\t\t// to report on; overwritten with the latest real snapshot once events start flowing.\n\t\tlet latest: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n\t\t\tapi: model.api,\n\t\t\tprovider: model.provider,\n\t\t\tmodel: model.id,\n\t\t\tusage: {\n\t\t\t\tinput: 0,\n\t\t\t\toutput: 0,\n\t\t\t\tcacheRead: 0,\n\t\t\t\tcacheWrite: 0,\n\t\t\t\ttotalTokens: 0,\n\t\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t\t},\n\t\t\tstopReason: \"stop\",\n\t\t\ttimestamp: Date.now(),\n\t\t};\n\t\tlet stalled = false;\n\t\tlet firstEventSeen = false;\n\n\t\t// The idle bound adapts per event: quiet while nothing/thinking, active while\n\t\t// text/toolCall content is flowing. Mutable so the onSilence closure always\n\t\t// reports the phase/bound that actually elapsed.\n\t\tlet currentPhase: StallPhase = \"connect\";\n\t\tlet currentBoundMs = opts.connectMs;\n\t\tconst idleBoundFor = (message: AssistantMessage): { phase: StallPhase; ms: number } => {\n\t\t\tconst lastBlock = message.content[message.content.length - 1];\n\t\t\treturn !lastBlock || lastBlock.type === \"thinking\"\n\t\t\t\t? { phase: \"quiet\", ms: opts.quietIdleMs }\n\t\t\t\t: { phase: \"active\", ms: opts.activeIdleMs };\n\t\t};\n\n\t\t// Emits the stall result directly (rather than after the inner loop finishes) so a\n\t\t// connection that never resolves at all still yields a result promptly — providers\n\t\t// are contractually expected to end their stream after abort, but the watchdog does\n\t\t// not depend on that to report the stall itself.\n\t\tconst stall = (phase: StallPhase, elapsedMs: number) => {\n\t\t\tif (callerAborted || stalled) return;\n\t\t\tstalled = true;\n\t\t\topts.onStall?.({ phase, elapsedMs });\n\t\t\tconst description = `stream stalled: no events for ${elapsedMs}ms (${phase} phase)`;\n\t\t\tcontroller.abort(new Error(description));\n\t\t\tconst message: AssistantMessage = {\n\t\t\t\t...latest,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terrorMessage: description,\n\t\t\t};\n\t\t\touter.push({ type: \"error\", reason: \"error\", error: message });\n\t\t};\n\n\t\tlet watchdog = createSilenceWatchdog({\n\t\t\tsilenceMs: opts.connectMs,\n\t\t\tonSilence: () => stall(currentPhase, currentBoundMs),\n\t\t});\n\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\tfor await (const event of inner) {\n\t\t\t\t\tif (stalled) break;\n\t\t\t\t\tlatest = partialFromEvent(event);\n\t\t\t\t\tconst bound = idleBoundFor(latest);\n\t\t\t\t\tcurrentPhase = bound.phase;\n\t\t\t\t\tcurrentBoundMs = bound.ms;\n\t\t\t\t\tif (!firstEventSeen) {\n\t\t\t\t\t\tfirstEventSeen = true;\n\t\t\t\t\t\twatchdog.disarm();\n\t\t\t\t\t\twatchdog = createSilenceWatchdog({\n\t\t\t\t\t\t\tsilenceMs: bound.ms,\n\t\t\t\t\t\t\tonSilence: () => stall(currentPhase, currentBoundMs),\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\twatchdog.touch(bound.ms);\n\t\t\t\t\t}\n\t\t\t\t\t// A terminal event ends the turn: disarm synchronously, in the same tick as\n\t\t\t\t\t// the push below, so no watchdog can fire after the consumer's `result()`\n\t\t\t\t\t// promise resolves — a disarm that only happened once the loop later notices\n\t\t\t\t\t// `inner` is done would race with that resolution (it runs a tick or more\n\t\t\t\t\t// later) and could fire a spurious stall on an already-finished stream.\n\t\t\t\t\tconst terminal = event.type === \"done\" || event.type === \"error\";\n\t\t\t\t\tif (terminal) {\n\t\t\t\t\t\twatchdog.disarm();\n\t\t\t\t\t\tcallerSignal?.removeEventListener(\"abort\", onCallerAbort);\n\t\t\t\t\t}\n\t\t\t\t\touter.push(event);\n\t\t\t\t\tif (terminal) return;\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\twatchdog.disarm();\n\t\t\t\tcallerSignal?.removeEventListener(\"abort\", onCallerAbort);\n\t\t\t}\n\t\t})();\n\n\t\treturn outer;\n\t};\n}\n"]}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Retention budget for tool result details kept in long-lived process memory.
3
+ *
4
+ * Tool result details are UI/log metadata, not provider-visible content. Large
5
+ * graph/search payloads can otherwise accumulate (live appends, session resume,
6
+ * scrollback components) until the interactive Node process hits the V8 heap limit.
7
+ */
8
+ export declare const MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES: number;
9
+ /**
10
+ * Retention budget for tool result details kept by live TUI scrollback components.
11
+ *
12
+ * Larger than the session budget so deliberately bounded display payloads (e.g. the
13
+ * bash tool's 50KB/2000-line output window) keep their designed expanded view, while
14
+ * pathological multi-megabyte payloads still get stubbed.
15
+ */
16
+ export declare const MAX_TUI_RETAINED_DETAILS_BYTES: number;
17
+ /** Replace oversized details on any retained holder with a small truncation stub. */
18
+ export declare function compactRetainedDetails(holder: {
19
+ details?: unknown;
20
+ }, maxBytes?: number): void;
21
+ /** Replace oversized tool result details with a small truncation stub. No-op for other roles. */
22
+ export declare function compactToolResultDetailsForRetention(message: {
23
+ role: string;
24
+ details?: unknown;
25
+ }): void;
26
+ //# sourceMappingURL=message-retention.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message-retention.d.ts","sourceRoot":"","sources":["../../src/session/message-retention.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,sCAAsC,QAAY,CAAC;AA8ChE;;;;;;GAMG;AACH,eAAO,MAAM,8BAA8B,QAAa,CAAC;AAEzD,qFAAqF;AACrF,wBAAgB,sBAAsB,CACrC,MAAM,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7B,QAAQ,SAAyC,GAC/C,IAAI,CAUN;AAED,iGAAiG;AACjG,wBAAgB,oCAAoC,CAAC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAGvG","sourcesContent":["/**\n * Retention budget for tool result details kept in long-lived process memory.\n *\n * Tool result details are UI/log metadata, not provider-visible content. Large\n * graph/search payloads can otherwise accumulate (live appends, session resume,\n * scrollback components) until the interactive Node process hits the V8 heap limit.\n */\nexport const MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES = 32 * 1024;\n\nfunction estimateJsonLikeBytes(value: unknown, maxBytes: number): { bytes: number; exceeded: boolean } {\n\tconst seen = new WeakSet<object>();\n\tlet bytes = 0;\n\tconst add = (amount: number): boolean => {\n\t\tbytes += amount;\n\t\treturn bytes <= maxBytes;\n\t};\n\tconst visit = (current: unknown): boolean => {\n\t\tif (bytes > maxBytes) return false;\n\t\tif (current === null) return add(4);\n\t\tif (current === undefined) return add(9);\n\t\tif (typeof current === \"string\") return add(current.length * 4 + 2);\n\t\tif (typeof current === \"number\") return add(24);\n\t\tif (typeof current === \"boolean\") return add(current ? 4 : 5);\n\t\tif (typeof current === \"bigint\") return add(current.toString().length + 2);\n\t\tif (typeof current === \"symbol\" || typeof current === \"function\") return add(12);\n\t\tif (typeof current !== \"object\") return add(8);\n\n\t\tconst objectValue = current as Record<string, unknown>;\n\t\tif (seen.has(objectValue)) return add(20);\n\t\tseen.add(objectValue);\n\t\tif (Array.isArray(objectValue)) {\n\t\t\tif (!add(2)) return false;\n\t\t\tfor (let index = 0; index < objectValue.length; index++) {\n\t\t\t\tif (!add(index === 0 ? 0 : 1)) return false;\n\t\t\t\tif (!visit(objectValue[index])) return false;\n\t\t\t}\n\t\t\treturn bytes <= maxBytes;\n\t\t}\n\n\t\tif (!add(2)) return false;\n\t\tlet first = true;\n\t\tfor (const key in objectValue) {\n\t\t\tif (!Object.hasOwn(objectValue, key)) continue;\n\t\t\tif (!add((first ? 0 : 1) + key.length * 4 + 3)) return false;\n\t\t\tfirst = false;\n\t\t\tif (!visit(objectValue[key])) return false;\n\t\t}\n\t\treturn bytes <= maxBytes;\n\t};\n\tvisit(value);\n\treturn { bytes, exceeded: bytes > maxBytes };\n}\n\n/**\n * Retention budget for tool result details kept by live TUI scrollback components.\n *\n * Larger than the session budget so deliberately bounded display payloads (e.g. the\n * bash tool's 50KB/2000-line output window) keep their designed expanded view, while\n * pathological multi-megabyte payloads still get stubbed.\n */\nexport const MAX_TUI_RETAINED_DETAILS_BYTES = 512 * 1024;\n\n/** Replace oversized details on any retained holder with a small truncation stub. */\nexport function compactRetainedDetails(\n\tholder: { details?: unknown },\n\tmaxBytes = MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES,\n): void {\n\tif (holder.details === undefined) return;\n\tconst estimate = estimateJsonLikeBytes(holder.details, maxBytes);\n\tif (!estimate.exceeded) return;\n\tholder.details = {\n\t\tpiToolResultDetailsTruncated: true,\n\t\treason: \"Tool result details exceeded retention budget; model-visible content was retained.\",\n\t\tminimumBytes: estimate.bytes,\n\t\tmaxRetainedBytes: maxBytes,\n\t};\n}\n\n/** Replace oversized tool result details with a small truncation stub. No-op for other roles. */\nexport function compactToolResultDetailsForRetention(message: { role: string; details?: unknown }): void {\n\tif (message.role !== \"toolResult\") return;\n\tcompactRetainedDetails(message);\n}\n"]}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Retention budget for tool result details kept in long-lived process memory.
3
+ *
4
+ * Tool result details are UI/log metadata, not provider-visible content. Large
5
+ * graph/search payloads can otherwise accumulate (live appends, session resume,
6
+ * scrollback components) until the interactive Node process hits the V8 heap limit.
7
+ */
8
+ export const MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES = 32 * 1024;
9
+ function estimateJsonLikeBytes(value, maxBytes) {
10
+ const seen = new WeakSet();
11
+ let bytes = 0;
12
+ const add = (amount) => {
13
+ bytes += amount;
14
+ return bytes <= maxBytes;
15
+ };
16
+ const visit = (current) => {
17
+ if (bytes > maxBytes)
18
+ return false;
19
+ if (current === null)
20
+ return add(4);
21
+ if (current === undefined)
22
+ return add(9);
23
+ if (typeof current === "string")
24
+ return add(current.length * 4 + 2);
25
+ if (typeof current === "number")
26
+ return add(24);
27
+ if (typeof current === "boolean")
28
+ return add(current ? 4 : 5);
29
+ if (typeof current === "bigint")
30
+ return add(current.toString().length + 2);
31
+ if (typeof current === "symbol" || typeof current === "function")
32
+ return add(12);
33
+ if (typeof current !== "object")
34
+ return add(8);
35
+ const objectValue = current;
36
+ if (seen.has(objectValue))
37
+ return add(20);
38
+ seen.add(objectValue);
39
+ if (Array.isArray(objectValue)) {
40
+ if (!add(2))
41
+ return false;
42
+ for (let index = 0; index < objectValue.length; index++) {
43
+ if (!add(index === 0 ? 0 : 1))
44
+ return false;
45
+ if (!visit(objectValue[index]))
46
+ return false;
47
+ }
48
+ return bytes <= maxBytes;
49
+ }
50
+ if (!add(2))
51
+ return false;
52
+ let first = true;
53
+ for (const key in objectValue) {
54
+ if (!Object.hasOwn(objectValue, key))
55
+ continue;
56
+ if (!add((first ? 0 : 1) + key.length * 4 + 3))
57
+ return false;
58
+ first = false;
59
+ if (!visit(objectValue[key]))
60
+ return false;
61
+ }
62
+ return bytes <= maxBytes;
63
+ };
64
+ visit(value);
65
+ return { bytes, exceeded: bytes > maxBytes };
66
+ }
67
+ /**
68
+ * Retention budget for tool result details kept by live TUI scrollback components.
69
+ *
70
+ * Larger than the session budget so deliberately bounded display payloads (e.g. the
71
+ * bash tool's 50KB/2000-line output window) keep their designed expanded view, while
72
+ * pathological multi-megabyte payloads still get stubbed.
73
+ */
74
+ export const MAX_TUI_RETAINED_DETAILS_BYTES = 512 * 1024;
75
+ /** Replace oversized details on any retained holder with a small truncation stub. */
76
+ export function compactRetainedDetails(holder, maxBytes = MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES) {
77
+ if (holder.details === undefined)
78
+ return;
79
+ const estimate = estimateJsonLikeBytes(holder.details, maxBytes);
80
+ if (!estimate.exceeded)
81
+ return;
82
+ holder.details = {
83
+ piToolResultDetailsTruncated: true,
84
+ reason: "Tool result details exceeded retention budget; model-visible content was retained.",
85
+ minimumBytes: estimate.bytes,
86
+ maxRetainedBytes: maxBytes,
87
+ };
88
+ }
89
+ /** Replace oversized tool result details with a small truncation stub. No-op for other roles. */
90
+ export function compactToolResultDetailsForRetention(message) {
91
+ if (message.role !== "toolResult")
92
+ return;
93
+ compactRetainedDetails(message);
94
+ }
95
+ //# sourceMappingURL=message-retention.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message-retention.js","sourceRoot":"","sources":["../../src/session/message-retention.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Retention budget for tool result details kept in long-lived process memory.\n *\n * Tool result details are UI/log metadata, not provider-visible content. Large\n * graph/search payloads can otherwise accumulate (live appends, session resume,\n * scrollback components) until the interactive Node process hits the V8 heap limit.\n */\nexport const MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES = 32 * 1024;\n\nfunction estimateJsonLikeBytes(value: unknown, maxBytes: number): { bytes: number; exceeded: boolean } {\n\tconst seen = new WeakSet<object>();\n\tlet bytes = 0;\n\tconst add = (amount: number): boolean => {\n\t\tbytes += amount;\n\t\treturn bytes <= maxBytes;\n\t};\n\tconst visit = (current: unknown): boolean => {\n\t\tif (bytes > maxBytes) return false;\n\t\tif (current === null) return add(4);\n\t\tif (current === undefined) return add(9);\n\t\tif (typeof current === \"string\") return add(current.length * 4 + 2);\n\t\tif (typeof current === \"number\") return add(24);\n\t\tif (typeof current === \"boolean\") return add(current ? 4 : 5);\n\t\tif (typeof current === \"bigint\") return add(current.toString().length + 2);\n\t\tif (typeof current === \"symbol\" || typeof current === \"function\") return add(12);\n\t\tif (typeof current !== \"object\") return add(8);\n\n\t\tconst objectValue = current as Record<string, unknown>;\n\t\tif (seen.has(objectValue)) return add(20);\n\t\tseen.add(objectValue);\n\t\tif (Array.isArray(objectValue)) {\n\t\t\tif (!add(2)) return false;\n\t\t\tfor (let index = 0; index < objectValue.length; index++) {\n\t\t\t\tif (!add(index === 0 ? 0 : 1)) return false;\n\t\t\t\tif (!visit(objectValue[index])) return false;\n\t\t\t}\n\t\t\treturn bytes <= maxBytes;\n\t\t}\n\n\t\tif (!add(2)) return false;\n\t\tlet first = true;\n\t\tfor (const key in objectValue) {\n\t\t\tif (!Object.hasOwn(objectValue, key)) continue;\n\t\t\tif (!add((first ? 0 : 1) + key.length * 4 + 3)) return false;\n\t\t\tfirst = false;\n\t\t\tif (!visit(objectValue[key])) return false;\n\t\t}\n\t\treturn bytes <= maxBytes;\n\t};\n\tvisit(value);\n\treturn { bytes, exceeded: bytes > maxBytes };\n}\n\n/**\n * Retention budget for tool result details kept by live TUI scrollback components.\n *\n * Larger than the session budget so deliberately bounded display payloads (e.g. the\n * bash tool's 50KB/2000-line output window) keep their designed expanded view, while\n * pathological multi-megabyte payloads still get stubbed.\n */\nexport const MAX_TUI_RETAINED_DETAILS_BYTES = 512 * 1024;\n\n/** Replace oversized details on any retained holder with a small truncation stub. */\nexport function compactRetainedDetails(\n\tholder: { details?: unknown },\n\tmaxBytes = MAX_RETAINED_TOOL_RESULT_DETAILS_BYTES,\n): void {\n\tif (holder.details === undefined) return;\n\tconst estimate = estimateJsonLikeBytes(holder.details, maxBytes);\n\tif (!estimate.exceeded) return;\n\tholder.details = {\n\t\tpiToolResultDetailsTruncated: true,\n\t\treason: \"Tool result details exceeded retention budget; model-visible content was retained.\",\n\t\tminimumBytes: estimate.bytes,\n\t\tmaxRetainedBytes: maxBytes,\n\t};\n}\n\n/** Replace oversized tool result details with a small truncation stub. No-op for other roles. */\nexport function compactToolResultDetailsForRetention(message: { role: string; details?: unknown }): void {\n\tif (message.role !== \"toolResult\") return;\n\tcompactRetainedDetails(message);\n}\n"]}