@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.
- package/dist/compaction/branch-summarization.d.ts +88 -0
- package/dist/compaction/branch-summarization.d.ts.map +1 -0
- package/dist/{harness/compaction → compaction}/branch-summarization.js +86 -18
- package/dist/compaction/branch-summarization.js.map +1 -0
- package/dist/compaction/compaction.d.ts +143 -0
- package/dist/compaction/compaction.d.ts.map +1 -0
- package/dist/{harness/compaction → compaction}/compaction.js +208 -77
- package/dist/compaction/compaction.js.map +1 -0
- package/dist/compaction/index.d.ts +7 -0
- package/dist/compaction/index.d.ts.map +1 -0
- package/dist/compaction/index.js +7 -0
- package/dist/compaction/index.js.map +1 -0
- package/dist/compaction/utils.d.ts +38 -0
- package/dist/compaction/utils.d.ts.map +1 -0
- package/dist/{harness/compaction → compaction}/utils.js +36 -14
- package/dist/compaction/utils.js.map +1 -0
- package/dist/index.d.ts +5 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -16
- package/dist/index.js.map +1 -1
- package/dist/{harness/messages.d.ts → messages.d.ts} +28 -2
- package/dist/messages.d.ts.map +1 -0
- package/dist/{harness/messages.js → messages.js} +22 -1
- package/dist/messages.js.map +1 -0
- package/dist/node.d.ts +5 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +9 -1
- package/dist/node.js.map +1 -1
- package/dist/reliability/classifier.d.ts +33 -0
- package/dist/reliability/classifier.d.ts.map +1 -0
- package/dist/reliability/classifier.js +69 -0
- package/dist/reliability/classifier.js.map +1 -0
- package/dist/reliability/index.d.ts +5 -0
- package/dist/reliability/index.d.ts.map +1 -0
- package/dist/reliability/index.js +5 -0
- package/dist/reliability/index.js.map +1 -0
- package/dist/reliability/node.d.ts +2 -0
- package/dist/reliability/node.d.ts.map +1 -0
- package/dist/reliability/node.js +2 -0
- package/dist/reliability/node.js.map +1 -0
- package/dist/reliability/process-tree.d.ts +10 -0
- package/dist/reliability/process-tree.d.ts.map +1 -0
- package/dist/reliability/process-tree.js +75 -0
- package/dist/reliability/process-tree.js.map +1 -0
- package/dist/reliability/retry-controller.d.ts +66 -0
- package/dist/reliability/retry-controller.d.ts.map +1 -0
- package/dist/reliability/retry-controller.js +104 -0
- package/dist/reliability/retry-controller.js.map +1 -0
- package/dist/reliability/retry.d.ts +19 -0
- package/dist/reliability/retry.d.ts.map +1 -0
- package/dist/reliability/retry.js +35 -0
- package/dist/reliability/retry.js.map +1 -0
- package/dist/reliability/watchdogs.d.ts +70 -0
- package/dist/reliability/watchdogs.d.ts.map +1 -0
- package/dist/reliability/watchdogs.js +203 -0
- package/dist/reliability/watchdogs.js.map +1 -0
- package/dist/session/message-retention.d.ts +26 -0
- package/dist/session/message-retention.d.ts.map +1 -0
- package/dist/session/message-retention.js +95 -0
- package/dist/session/message-retention.js.map +1 -0
- package/dist/session/session-manager.d.ts +350 -0
- package/dist/session/session-manager.d.ts.map +1 -0
- package/dist/session/session-manager.js +1339 -0
- package/dist/session/session-manager.js.map +1 -0
- package/dist/utils/paths.d.ts +15 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +31 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/shell-output.d.ts +10 -0
- package/dist/utils/shell-output.d.ts.map +1 -0
- package/dist/utils/shell-output.js +38 -0
- package/dist/utils/shell-output.js.map +1 -0
- package/dist/utils/truncate.d.ts.map +1 -0
- package/dist/{harness/utils → utils}/truncate.js +27 -102
- package/dist/utils/truncate.js.map +1 -0
- package/dist/uuid.d.ts.map +1 -0
- package/dist/uuid.js.map +1 -0
- package/package.json +6 -2
- package/dist/harness/agent-harness.d.ts +0 -95
- package/dist/harness/agent-harness.d.ts.map +0 -1
- package/dist/harness/agent-harness.js +0 -963
- package/dist/harness/agent-harness.js.map +0 -1
- package/dist/harness/compaction/branch-summarization.d.ts +0 -53
- package/dist/harness/compaction/branch-summarization.d.ts.map +0 -1
- package/dist/harness/compaction/branch-summarization.js.map +0 -1
- package/dist/harness/compaction/compaction.d.ts +0 -95
- package/dist/harness/compaction/compaction.d.ts.map +0 -1
- package/dist/harness/compaction/compaction.js.map +0 -1
- package/dist/harness/compaction/utils.d.ts +0 -25
- package/dist/harness/compaction/utils.d.ts.map +0 -1
- package/dist/harness/compaction/utils.js.map +0 -1
- package/dist/harness/env/nodejs.d.ts +0 -51
- package/dist/harness/env/nodejs.d.ts.map +0 -1
- package/dist/harness/env/nodejs.js +0 -486
- package/dist/harness/env/nodejs.js.map +0 -1
- package/dist/harness/messages.d.ts.map +0 -1
- package/dist/harness/messages.js.map +0 -1
- package/dist/harness/prompt-templates.d.ts +0 -48
- package/dist/harness/prompt-templates.d.ts.map +0 -1
- package/dist/harness/prompt-templates.js +0 -233
- package/dist/harness/prompt-templates.js.map +0 -1
- package/dist/harness/session/jsonl-repo.d.ts +0 -28
- package/dist/harness/session/jsonl-repo.d.ts.map +0 -1
- package/dist/harness/session/jsonl-repo.js +0 -112
- package/dist/harness/session/jsonl-repo.js.map +0 -1
- package/dist/harness/session/jsonl-storage.d.ts +0 -33
- package/dist/harness/session/jsonl-storage.d.ts.map +0 -1
- package/dist/harness/session/jsonl-storage.js +0 -231
- package/dist/harness/session/jsonl-storage.js.map +0 -1
- package/dist/harness/session/memory-repo.d.ts +0 -18
- package/dist/harness/session/memory-repo.d.ts.map +0 -1
- package/dist/harness/session/memory-repo.js +0 -42
- package/dist/harness/session/memory-repo.js.map +0 -1
- package/dist/harness/session/memory-storage.d.ts +0 -25
- package/dist/harness/session/memory-storage.d.ts.map +0 -1
- package/dist/harness/session/memory-storage.js +0 -114
- package/dist/harness/session/memory-storage.js.map +0 -1
- package/dist/harness/session/repo-utils.d.ts +0 -11
- package/dist/harness/session/repo-utils.d.ts.map +0 -1
- package/dist/harness/session/repo-utils.js +0 -39
- package/dist/harness/session/repo-utils.js.map +0 -1
- package/dist/harness/session/session.d.ts +0 -33
- package/dist/harness/session/session.d.ts.map +0 -1
- package/dist/harness/session/session.js +0 -210
- package/dist/harness/session/session.js.map +0 -1
- package/dist/harness/session/uuid.d.ts.map +0 -1
- package/dist/harness/session/uuid.js.map +0 -1
- package/dist/harness/skills.d.ts +0 -44
- package/dist/harness/skills.d.ts.map +0 -1
- package/dist/harness/skills.js +0 -311
- package/dist/harness/skills.js.map +0 -1
- package/dist/harness/system-prompt.d.ts +0 -3
- package/dist/harness/system-prompt.d.ts.map +0 -1
- package/dist/harness/system-prompt.js +0 -30
- package/dist/harness/system-prompt.js.map +0 -1
- package/dist/harness/types.d.ts +0 -615
- package/dist/harness/types.d.ts.map +0 -1
- package/dist/harness/types.js +0 -94
- package/dist/harness/types.js.map +0 -1
- package/dist/harness/utils/shell-output.d.ts +0 -14
- package/dist/harness/utils/shell-output.d.ts.map +0 -1
- package/dist/harness/utils/shell-output.js +0 -126
- package/dist/harness/utils/shell-output.js.map +0 -1
- package/dist/harness/utils/truncate.d.ts.map +0 -1
- package/dist/harness/utils/truncate.js.map +0 -1
- /package/dist/{harness/utils → utils}/truncate.d.ts +0 -0
- /package/dist/{harness/session/uuid.d.ts → uuid.d.ts} +0 -0
- /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"]}
|