@caupulican/pi-agent-core 0.80.103 → 0.81.0
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 +47 -0
- package/dist/reliability/watchdogs.d.ts.map +1 -0
- package/dist/reliability/watchdogs.js +159 -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
package/dist/node.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
// Compaction + branch summarization (Node-only: transitively imports session storage via buildSessionContext)
|
|
2
|
+
export * from "./compaction/index.js";
|
|
2
3
|
export * from "./index.js";
|
|
4
|
+
export * from "./reliability/node.js";
|
|
5
|
+
// Session storage (Node-only: fs/readline)
|
|
6
|
+
export * from "./session/session-manager.js";
|
|
7
|
+
// Path normalization/resolution (Node-only: node:os/node:path/node:url)
|
|
8
|
+
export * from "./utils/paths.js";
|
|
9
|
+
// Truncation utilities (Node-only: Buffer-based byte-length math)
|
|
10
|
+
export * from "./utils/truncate.js";
|
|
3
11
|
//# sourceMappingURL=node.js.map
|
package/dist/node.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node.js","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"node.js","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,8GAA8G;AAC9G,cAAc,uBAAuB,CAAC;AACtC,cAAc,YAAY,CAAC;AAC3B,cAAc,uBAAuB,CAAC;AACtC,2CAA2C;AAC3C,cAAc,8BAA8B,CAAC;AAC7C,wEAAwE;AACxE,cAAc,kBAAkB,CAAC;AACjC,kEAAkE;AAClE,cAAc,qBAAqB,CAAC","sourcesContent":["// Compaction + branch summarization (Node-only: transitively imports session storage via buildSessionContext)\nexport * from \"./compaction/index.ts\";\nexport * from \"./index.ts\";\nexport * from \"./reliability/node.ts\";\n// Session storage (Node-only: fs/readline)\nexport * from \"./session/session-manager.ts\";\n// Path normalization/resolution (Node-only: node:os/node:path/node:url)\nexport * from \"./utils/paths.ts\";\n// Truncation utilities (Node-only: Buffer-based byte-length math)\nexport * from \"./utils/truncate.ts\";\n"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure provider-failure classifier.
|
|
3
|
+
*
|
|
4
|
+
* One classification produces four independent action booleans (hermes-derived design):
|
|
5
|
+
* the retry loop, compaction, credential rotation, and provider failover each read their
|
|
6
|
+
* own flag, so one pipeline can route a 429 to rotation, an overflow to compaction, and a
|
|
7
|
+
* billing error to failover without re-parsing error text at each site.
|
|
8
|
+
*
|
|
9
|
+
* Pattern sources: AgentSession._isRetryableError / _isNonRetryableProviderLimitError
|
|
10
|
+
* (the live, battle-tested regexes), split by reason so each maps to distinct actions.
|
|
11
|
+
* Detection of context overflow stays in @caupulican/pi-ai (needs model state); hosts pass
|
|
12
|
+
* `contextOverflow` in.
|
|
13
|
+
*/
|
|
14
|
+
export type FailureReason = "overloaded" | "rate_limit" | "server_error" | "network" | "stream_stall" | "context_overflow" | "auth" | "billing_or_quota" | "aborted" | "unknown";
|
|
15
|
+
export interface ClassifiedError {
|
|
16
|
+
reason: FailureReason;
|
|
17
|
+
retryable: boolean;
|
|
18
|
+
shouldCompact: boolean;
|
|
19
|
+
shouldRotateCredential: boolean;
|
|
20
|
+
shouldFallback: boolean;
|
|
21
|
+
/** Provider-suggested delay parsed from the message, capped by the retry policy at use site. */
|
|
22
|
+
retryAfterMs?: number;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ClassifyFailureInput {
|
|
26
|
+
message: string;
|
|
27
|
+
/** Host-computed via pi-ai isContextOverflow(message, contextWindow). */
|
|
28
|
+
contextOverflow?: boolean;
|
|
29
|
+
/** True when the failure came from an intentional abort (stopReason "aborted"). */
|
|
30
|
+
aborted?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare function classifyFailure(input: ClassifyFailureInput): ClassifiedError;
|
|
33
|
+
//# sourceMappingURL=classifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classifier.d.ts","sourceRoot":"","sources":["../../src/reliability/classifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,MAAM,aAAa,GACtB,YAAY,GACZ,YAAY,GACZ,cAAc,GACd,SAAS,GACT,cAAc,GACd,kBAAkB,GAClB,MAAM,GACN,kBAAkB,GAClB,SAAS,GACT,SAAS,CAAC;AAEb,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,OAAO,CAAC;IACvB,sBAAsB,EAAE,OAAO,CAAC;IAChC,cAAc,EAAE,OAAO,CAAC;IACxB,gGAAgG;IAChG,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,mFAAmF;IACnF,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB;AAwBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,eAAe,CAsC5E","sourcesContent":["/**\n * Pure provider-failure classifier.\n *\n * One classification produces four independent action booleans (hermes-derived design):\n * the retry loop, compaction, credential rotation, and provider failover each read their\n * own flag, so one pipeline can route a 429 to rotation, an overflow to compaction, and a\n * billing error to failover without re-parsing error text at each site.\n *\n * Pattern sources: AgentSession._isRetryableError / _isNonRetryableProviderLimitError\n * (the live, battle-tested regexes), split by reason so each maps to distinct actions.\n * Detection of context overflow stays in @caupulican/pi-ai (needs model state); hosts pass\n * `contextOverflow` in.\n */\n\nexport type FailureReason =\n\t| \"overloaded\"\n\t| \"rate_limit\"\n\t| \"server_error\"\n\t| \"network\"\n\t| \"stream_stall\"\n\t| \"context_overflow\"\n\t| \"auth\"\n\t| \"billing_or_quota\"\n\t| \"aborted\"\n\t| \"unknown\";\n\nexport interface ClassifiedError {\n\treason: FailureReason;\n\tretryable: boolean;\n\tshouldCompact: boolean;\n\tshouldRotateCredential: boolean;\n\tshouldFallback: boolean;\n\t/** Provider-suggested delay parsed from the message, capped by the retry policy at use site. */\n\tretryAfterMs?: number;\n\tmessage: string;\n}\n\nexport interface ClassifyFailureInput {\n\tmessage: string;\n\t/** Host-computed via pi-ai isContextOverflow(message, contextWindow). */\n\tcontextOverflow?: boolean;\n\t/** True when the failure came from an intentional abort (stopReason \"aborted\"). */\n\taborted?: boolean;\n}\n\nconst BILLING_OR_QUOTA =\n\t/GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i;\nconst AUTH = /\\b401\\b|unauthorized|invalid.?api.?key|authentication.?error|forbidden|permission.?denied/i;\nconst RATE_LIMIT = /rate.?limit|too many requests|429/i;\nconst OVERLOADED = /overloaded/i;\nconst STREAM_STALL = /stream stalled|ended without|stream ended before message_stop|reset before headers/i;\nconst SERVER_ERROR =\n\t/500|502|503|504|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|upstream.?connect|http2 request did not get a response|retry delay/i;\nconst NETWORK =\n\t/network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|socket hang up|timed? out|timeout|terminated/i;\n\nconst RETRY_AFTER_S = /retry.?(?:after|in)\\s+(\\d+(?:\\.\\d+)?)\\s*s\\b|\"retryDelay\"\\s*:\\s*\"(\\d+(?:\\.\\d+)?)s\"/i;\nconst RETRY_AFTER_MS = /retry.?(?:after|in)\\s+(\\d+)\\s*ms\\b/i;\n\nfunction parseRetryAfterMs(message: string): number | undefined {\n\tconst ms = RETRY_AFTER_MS.exec(message);\n\tif (ms) return Number(ms[1]);\n\tconst s = RETRY_AFTER_S.exec(message);\n\tif (s) return Math.round(Number(s[1] ?? s[2]) * 1000);\n\treturn undefined;\n}\n\nexport function classifyFailure(input: ClassifyFailureInput): ClassifiedError {\n\tconst message = input.message;\n\tconst retryAfterMs = parseRetryAfterMs(message);\n\n\tconst base = {\n\t\tretryable: false,\n\t\tshouldCompact: false,\n\t\tshouldRotateCredential: false,\n\t\tshouldFallback: false,\n\t\tmessage,\n\t};\n\n\tconst withRetry = <T extends { reason: FailureReason }>(obj: T): T | (T & { retryAfterMs: number }) =>\n\t\tretryAfterMs !== undefined ? { ...obj, retryAfterMs } : obj;\n\n\tif (input.aborted) return withRetry({ ...base, reason: \"aborted\" });\n\tif (input.contextOverflow) return withRetry({ ...base, reason: \"context_overflow\", shouldCompact: true });\n\tif (BILLING_OR_QUOTA.test(message)) return withRetry({ ...base, reason: \"billing_or_quota\", shouldFallback: true });\n\tif (AUTH.test(message))\n\t\treturn withRetry({ ...base, reason: \"auth\", shouldRotateCredential: true, shouldFallback: true });\n\n\tconst isRateLimit = RATE_LIMIT.test(message);\n\tif (isRateLimit || OVERLOADED.test(message)) {\n\t\treturn withRetry({\n\t\t\t...base,\n\t\t\treason: isRateLimit ? \"rate_limit\" : \"overloaded\",\n\t\t\tretryable: true,\n\t\t\tshouldRotateCredential: true,\n\t\t\tshouldFallback: true,\n\t\t});\n\t}\n\tif (STREAM_STALL.test(message))\n\t\treturn withRetry({ ...base, reason: \"stream_stall\", retryable: true, shouldFallback: true });\n\tif (SERVER_ERROR.test(message))\n\t\treturn withRetry({ ...base, reason: \"server_error\", retryable: true, shouldFallback: true });\n\tif (NETWORK.test(message)) return withRetry({ ...base, reason: \"network\", retryable: true, shouldFallback: true });\n\n\treturn withRetry({ ...base, reason: \"unknown\" });\n}\n"]}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure provider-failure classifier.
|
|
3
|
+
*
|
|
4
|
+
* One classification produces four independent action booleans (hermes-derived design):
|
|
5
|
+
* the retry loop, compaction, credential rotation, and provider failover each read their
|
|
6
|
+
* own flag, so one pipeline can route a 429 to rotation, an overflow to compaction, and a
|
|
7
|
+
* billing error to failover without re-parsing error text at each site.
|
|
8
|
+
*
|
|
9
|
+
* Pattern sources: AgentSession._isRetryableError / _isNonRetryableProviderLimitError
|
|
10
|
+
* (the live, battle-tested regexes), split by reason so each maps to distinct actions.
|
|
11
|
+
* Detection of context overflow stays in @caupulican/pi-ai (needs model state); hosts pass
|
|
12
|
+
* `contextOverflow` in.
|
|
13
|
+
*/
|
|
14
|
+
const BILLING_OR_QUOTA = /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i;
|
|
15
|
+
const AUTH = /\b401\b|unauthorized|invalid.?api.?key|authentication.?error|forbidden|permission.?denied/i;
|
|
16
|
+
const RATE_LIMIT = /rate.?limit|too many requests|429/i;
|
|
17
|
+
const OVERLOADED = /overloaded/i;
|
|
18
|
+
const STREAM_STALL = /stream stalled|ended without|stream ended before message_stop|reset before headers/i;
|
|
19
|
+
const SERVER_ERROR = /500|502|503|504|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|upstream.?connect|http2 request did not get a response|retry delay/i;
|
|
20
|
+
const NETWORK = /network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|socket hang up|timed? out|timeout|terminated/i;
|
|
21
|
+
const RETRY_AFTER_S = /retry.?(?:after|in)\s+(\d+(?:\.\d+)?)\s*s\b|"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/i;
|
|
22
|
+
const RETRY_AFTER_MS = /retry.?(?:after|in)\s+(\d+)\s*ms\b/i;
|
|
23
|
+
function parseRetryAfterMs(message) {
|
|
24
|
+
const ms = RETRY_AFTER_MS.exec(message);
|
|
25
|
+
if (ms)
|
|
26
|
+
return Number(ms[1]);
|
|
27
|
+
const s = RETRY_AFTER_S.exec(message);
|
|
28
|
+
if (s)
|
|
29
|
+
return Math.round(Number(s[1] ?? s[2]) * 1000);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
export function classifyFailure(input) {
|
|
33
|
+
const message = input.message;
|
|
34
|
+
const retryAfterMs = parseRetryAfterMs(message);
|
|
35
|
+
const base = {
|
|
36
|
+
retryable: false,
|
|
37
|
+
shouldCompact: false,
|
|
38
|
+
shouldRotateCredential: false,
|
|
39
|
+
shouldFallback: false,
|
|
40
|
+
message,
|
|
41
|
+
};
|
|
42
|
+
const withRetry = (obj) => retryAfterMs !== undefined ? { ...obj, retryAfterMs } : obj;
|
|
43
|
+
if (input.aborted)
|
|
44
|
+
return withRetry({ ...base, reason: "aborted" });
|
|
45
|
+
if (input.contextOverflow)
|
|
46
|
+
return withRetry({ ...base, reason: "context_overflow", shouldCompact: true });
|
|
47
|
+
if (BILLING_OR_QUOTA.test(message))
|
|
48
|
+
return withRetry({ ...base, reason: "billing_or_quota", shouldFallback: true });
|
|
49
|
+
if (AUTH.test(message))
|
|
50
|
+
return withRetry({ ...base, reason: "auth", shouldRotateCredential: true, shouldFallback: true });
|
|
51
|
+
const isRateLimit = RATE_LIMIT.test(message);
|
|
52
|
+
if (isRateLimit || OVERLOADED.test(message)) {
|
|
53
|
+
return withRetry({
|
|
54
|
+
...base,
|
|
55
|
+
reason: isRateLimit ? "rate_limit" : "overloaded",
|
|
56
|
+
retryable: true,
|
|
57
|
+
shouldRotateCredential: true,
|
|
58
|
+
shouldFallback: true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (STREAM_STALL.test(message))
|
|
62
|
+
return withRetry({ ...base, reason: "stream_stall", retryable: true, shouldFallback: true });
|
|
63
|
+
if (SERVER_ERROR.test(message))
|
|
64
|
+
return withRetry({ ...base, reason: "server_error", retryable: true, shouldFallback: true });
|
|
65
|
+
if (NETWORK.test(message))
|
|
66
|
+
return withRetry({ ...base, reason: "network", retryable: true, shouldFallback: true });
|
|
67
|
+
return withRetry({ ...base, reason: "unknown" });
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=classifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classifier.js","sourceRoot":"","sources":["../../src/reliability/classifier.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pure provider-failure classifier.\n *\n * One classification produces four independent action booleans (hermes-derived design):\n * the retry loop, compaction, credential rotation, and provider failover each read their\n * own flag, so one pipeline can route a 429 to rotation, an overflow to compaction, and a\n * billing error to failover without re-parsing error text at each site.\n *\n * Pattern sources: AgentSession._isRetryableError / _isNonRetryableProviderLimitError\n * (the live, battle-tested regexes), split by reason so each maps to distinct actions.\n * Detection of context overflow stays in @caupulican/pi-ai (needs model state); hosts pass\n * `contextOverflow` in.\n */\n\nexport type FailureReason =\n\t| \"overloaded\"\n\t| \"rate_limit\"\n\t| \"server_error\"\n\t| \"network\"\n\t| \"stream_stall\"\n\t| \"context_overflow\"\n\t| \"auth\"\n\t| \"billing_or_quota\"\n\t| \"aborted\"\n\t| \"unknown\";\n\nexport interface ClassifiedError {\n\treason: FailureReason;\n\tretryable: boolean;\n\tshouldCompact: boolean;\n\tshouldRotateCredential: boolean;\n\tshouldFallback: boolean;\n\t/** Provider-suggested delay parsed from the message, capped by the retry policy at use site. */\n\tretryAfterMs?: number;\n\tmessage: string;\n}\n\nexport interface ClassifyFailureInput {\n\tmessage: string;\n\t/** Host-computed via pi-ai isContextOverflow(message, contextWindow). */\n\tcontextOverflow?: boolean;\n\t/** True when the failure came from an intentional abort (stopReason \"aborted\"). */\n\taborted?: boolean;\n}\n\nconst BILLING_OR_QUOTA =\n\t/GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i;\nconst AUTH = /\\b401\\b|unauthorized|invalid.?api.?key|authentication.?error|forbidden|permission.?denied/i;\nconst RATE_LIMIT = /rate.?limit|too many requests|429/i;\nconst OVERLOADED = /overloaded/i;\nconst STREAM_STALL = /stream stalled|ended without|stream ended before message_stop|reset before headers/i;\nconst SERVER_ERROR =\n\t/500|502|503|504|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|upstream.?connect|http2 request did not get a response|retry delay/i;\nconst NETWORK =\n\t/network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|socket hang up|timed? out|timeout|terminated/i;\n\nconst RETRY_AFTER_S = /retry.?(?:after|in)\\s+(\\d+(?:\\.\\d+)?)\\s*s\\b|\"retryDelay\"\\s*:\\s*\"(\\d+(?:\\.\\d+)?)s\"/i;\nconst RETRY_AFTER_MS = /retry.?(?:after|in)\\s+(\\d+)\\s*ms\\b/i;\n\nfunction parseRetryAfterMs(message: string): number | undefined {\n\tconst ms = RETRY_AFTER_MS.exec(message);\n\tif (ms) return Number(ms[1]);\n\tconst s = RETRY_AFTER_S.exec(message);\n\tif (s) return Math.round(Number(s[1] ?? s[2]) * 1000);\n\treturn undefined;\n}\n\nexport function classifyFailure(input: ClassifyFailureInput): ClassifiedError {\n\tconst message = input.message;\n\tconst retryAfterMs = parseRetryAfterMs(message);\n\n\tconst base = {\n\t\tretryable: false,\n\t\tshouldCompact: false,\n\t\tshouldRotateCredential: false,\n\t\tshouldFallback: false,\n\t\tmessage,\n\t};\n\n\tconst withRetry = <T extends { reason: FailureReason }>(obj: T): T | (T & { retryAfterMs: number }) =>\n\t\tretryAfterMs !== undefined ? { ...obj, retryAfterMs } : obj;\n\n\tif (input.aborted) return withRetry({ ...base, reason: \"aborted\" });\n\tif (input.contextOverflow) return withRetry({ ...base, reason: \"context_overflow\", shouldCompact: true });\n\tif (BILLING_OR_QUOTA.test(message)) return withRetry({ ...base, reason: \"billing_or_quota\", shouldFallback: true });\n\tif (AUTH.test(message))\n\t\treturn withRetry({ ...base, reason: \"auth\", shouldRotateCredential: true, shouldFallback: true });\n\n\tconst isRateLimit = RATE_LIMIT.test(message);\n\tif (isRateLimit || OVERLOADED.test(message)) {\n\t\treturn withRetry({\n\t\t\t...base,\n\t\t\treason: isRateLimit ? \"rate_limit\" : \"overloaded\",\n\t\t\tretryable: true,\n\t\t\tshouldRotateCredential: true,\n\t\t\tshouldFallback: true,\n\t\t});\n\t}\n\tif (STREAM_STALL.test(message))\n\t\treturn withRetry({ ...base, reason: \"stream_stall\", retryable: true, shouldFallback: true });\n\tif (SERVER_ERROR.test(message))\n\t\treturn withRetry({ ...base, reason: \"server_error\", retryable: true, shouldFallback: true });\n\tif (NETWORK.test(message)) return withRetry({ ...base, reason: \"network\", retryable: true, shouldFallback: true });\n\n\treturn withRetry({ ...base, reason: \"unknown\" });\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/reliability/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC","sourcesContent":["export * from \"./classifier.ts\";\nexport * from \"./retry.ts\";\nexport * from \"./retry-controller.ts\";\nexport * from \"./watchdogs.ts\";\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/reliability/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC","sourcesContent":["export * from \"./classifier.ts\";\nexport * from \"./retry.ts\";\nexport * from \"./retry-controller.ts\";\nexport * from \"./watchdogs.ts\";\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/reliability/node.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC","sourcesContent":["export * from \"./process-tree.ts\";\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.js","sourceRoot":"","sources":["../../src/reliability/node.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC","sourcesContent":["export * from \"./process-tree.ts\";\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
2
|
+
export interface KillTreeOptions {
|
|
3
|
+
/** How long to wait for SIGTERM to work before SIGKILL. Default 5000ms. */
|
|
4
|
+
graceMs?: number;
|
|
5
|
+
}
|
|
6
|
+
/** Graceful tree kill: SIGTERM → poll liveness → SIGKILL. */
|
|
7
|
+
export declare function killTree(pid: number, opts?: KillTreeOptions): Promise<"already_dead" | "terminated" | "killed">;
|
|
8
|
+
/** Immediate tree kill (SIGKILL / taskkill). Port of coding-agent's killProcessTree. */
|
|
9
|
+
export declare function killTreeNow(pid: number): void;
|
|
10
|
+
//# sourceMappingURL=process-tree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-tree.d.ts","sourceRoot":"","sources":["../../src/reliability/process-tree.ts"],"names":[],"mappings":"AAaA,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQnD;AAgBD,MAAM,WAAW,eAAe;IAC/B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,iEAA6D;AAC7D,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,GAAG,YAAY,GAAG,QAAQ,CAAC,CAiBrH;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAU7C","sourcesContent":["/**\n * Process-tree kill primitives.\n *\n * WHY: node's ChildProcess.killed flag is set the moment a signal is SENT, not when\n * the process dies — gating SIGKILL escalation on it (as the legacy exec path did)\n * makes escalation dead code, so a SIGTERM-trapping child survives forever. These\n * primitives escalate based on actual liveness probes instead.\n *\n * Group kill (-pid) requires the target to be a process-group leader (spawned with\n * detached: true). Both functions fall back to single-pid signaling otherwise.\n */\nimport { spawn } from \"node:child_process\";\n\nexport function isProcessAlive(pid: number): boolean {\n\ttry {\n\t\tprocess.kill(pid, 0);\n\t\treturn true;\n\t} catch (err) {\n\t\t// EPERM means it exists but we lack permission — still alive.\n\t\treturn (err as NodeJS.ErrnoException).code === \"EPERM\";\n\t}\n}\n\nfunction signalTree(pid: number, signal: NodeJS.Signals): boolean {\n\ttry {\n\t\tprocess.kill(-pid, signal);\n\t\treturn true;\n\t} catch {\n\t\ttry {\n\t\t\tprocess.kill(pid, signal);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\nexport interface KillTreeOptions {\n\t/** How long to wait for SIGTERM to work before SIGKILL. Default 5000ms. */\n\tgraceMs?: number;\n}\n\n/** Graceful tree kill: SIGTERM → poll liveness → SIGKILL. */\nexport async function killTree(pid: number, opts?: KillTreeOptions): Promise<\"already_dead\" | \"terminated\" | \"killed\"> {\n\tif (process.platform === \"win32\") {\n\t\tif (!isProcessAlive(pid)) return \"already_dead\";\n\t\tkillTreeNow(pid);\n\t\treturn \"killed\";\n\t}\n\tif (!isProcessAlive(pid)) return \"already_dead\";\n\tsignalTree(pid, \"SIGTERM\");\n\tconst graceMs = opts?.graceMs ?? 5000;\n\tconst deadline = Date.now() + graceMs;\n\twhile (Date.now() < deadline) {\n\t\tif (!isProcessAlive(pid)) return \"terminated\";\n\t\tawait new Promise((resolve) => setTimeout(resolve, Math.min(50, graceMs)));\n\t}\n\tif (!isProcessAlive(pid)) return \"terminated\";\n\tsignalTree(pid, \"SIGKILL\");\n\treturn \"killed\";\n}\n\n/** Immediate tree kill (SIGKILL / taskkill). Port of coding-agent's killProcessTree. */\nexport function killTreeNow(pid: number): void {\n\tif (process.platform === \"win32\") {\n\t\ttry {\n\t\t\tspawn(\"taskkill\", [\"/F\", \"/T\", \"/PID\", String(pid)], { stdio: \"ignore\", detached: true, windowsHide: true });\n\t\t} catch {\n\t\t\t// Ignore errors if taskkill fails.\n\t\t}\n\t} else {\n\t\tsignalTree(pid, \"SIGKILL\");\n\t}\n}\n"]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-tree kill primitives.
|
|
3
|
+
*
|
|
4
|
+
* WHY: node's ChildProcess.killed flag is set the moment a signal is SENT, not when
|
|
5
|
+
* the process dies — gating SIGKILL escalation on it (as the legacy exec path did)
|
|
6
|
+
* makes escalation dead code, so a SIGTERM-trapping child survives forever. These
|
|
7
|
+
* primitives escalate based on actual liveness probes instead.
|
|
8
|
+
*
|
|
9
|
+
* Group kill (-pid) requires the target to be a process-group leader (spawned with
|
|
10
|
+
* detached: true). Both functions fall back to single-pid signaling otherwise.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
export function isProcessAlive(pid) {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
// EPERM means it exists but we lack permission — still alive.
|
|
20
|
+
return err.code === "EPERM";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function signalTree(pid, signal) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(-pid, signal);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, signal);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Graceful tree kill: SIGTERM → poll liveness → SIGKILL. */
|
|
39
|
+
export async function killTree(pid, opts) {
|
|
40
|
+
if (process.platform === "win32") {
|
|
41
|
+
if (!isProcessAlive(pid))
|
|
42
|
+
return "already_dead";
|
|
43
|
+
killTreeNow(pid);
|
|
44
|
+
return "killed";
|
|
45
|
+
}
|
|
46
|
+
if (!isProcessAlive(pid))
|
|
47
|
+
return "already_dead";
|
|
48
|
+
signalTree(pid, "SIGTERM");
|
|
49
|
+
const graceMs = opts?.graceMs ?? 5000;
|
|
50
|
+
const deadline = Date.now() + graceMs;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (!isProcessAlive(pid))
|
|
53
|
+
return "terminated";
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, graceMs)));
|
|
55
|
+
}
|
|
56
|
+
if (!isProcessAlive(pid))
|
|
57
|
+
return "terminated";
|
|
58
|
+
signalTree(pid, "SIGKILL");
|
|
59
|
+
return "killed";
|
|
60
|
+
}
|
|
61
|
+
/** Immediate tree kill (SIGKILL / taskkill). Port of coding-agent's killProcessTree. */
|
|
62
|
+
export function killTreeNow(pid) {
|
|
63
|
+
if (process.platform === "win32") {
|
|
64
|
+
try {
|
|
65
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, windowsHide: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Ignore errors if taskkill fails.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
signalTree(pid, "SIGKILL");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=process-tree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-tree.js","sourceRoot":"","sources":["../../src/reliability/process-tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,MAAM,UAAU,cAAc,CAAC,GAAW,EAAW;IACpD,IAAI,CAAC;QACJ,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;QACrB,OAAO,IAAI,CAAC;IACb,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACd,gEAA8D;QAC9D,OAAQ,GAA6B,CAAC,IAAI,KAAK,OAAO,CAAC;IACxD,CAAC;AAAA,CACD;AAED,SAAS,UAAU,CAAC,GAAW,EAAE,MAAsB,EAAW;IACjE,IAAI,CAAC;QACJ,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAC3B,OAAO,IAAI,CAAC;IACb,CAAC;IAAC,MAAM,CAAC;QACR,IAAI,CAAC;YACJ,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;YAC1B,OAAO,IAAI,CAAC;QACb,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,KAAK,CAAC;QACd,CAAC;IACF,CAAC;AAAA,CACD;AAOD,iEAA6D;AAC7D,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,GAAW,EAAE,IAAsB,EAAqD;IACtH,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAClC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC;YAAE,OAAO,cAAc,CAAC;QAChD,WAAW,CAAC,GAAG,CAAC,CAAC;QACjB,OAAO,QAAQ,CAAC;IACjB,CAAC;IACD,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC;QAAE,OAAO,cAAc,CAAC;IAChD,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;IAC3B,MAAM,OAAO,GAAG,IAAI,EAAE,OAAO,IAAI,IAAI,CAAC;IACtC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;IACtC,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,EAAE,CAAC;QAC9B,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC;YAAE,OAAO,YAAY,CAAC;QAC9C,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5E,CAAC;IACD,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC;QAAE,OAAO,YAAY,CAAC;IAC9C,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;IAC3B,OAAO,QAAQ,CAAC;AAAA,CAChB;AAED,wFAAwF;AACxF,MAAM,UAAU,WAAW,CAAC,GAAW,EAAQ;IAC9C,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAClC,IAAI,CAAC;YACJ,KAAK,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9G,CAAC;QAAC,MAAM,CAAC;YACR,mCAAmC;QACpC,CAAC;IACF,CAAC;SAAM,CAAC;QACP,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;IAC5B,CAAC;AAAA,CACD","sourcesContent":["/**\n * Process-tree kill primitives.\n *\n * WHY: node's ChildProcess.killed flag is set the moment a signal is SENT, not when\n * the process dies — gating SIGKILL escalation on it (as the legacy exec path did)\n * makes escalation dead code, so a SIGTERM-trapping child survives forever. These\n * primitives escalate based on actual liveness probes instead.\n *\n * Group kill (-pid) requires the target to be a process-group leader (spawned with\n * detached: true). Both functions fall back to single-pid signaling otherwise.\n */\nimport { spawn } from \"node:child_process\";\n\nexport function isProcessAlive(pid: number): boolean {\n\ttry {\n\t\tprocess.kill(pid, 0);\n\t\treturn true;\n\t} catch (err) {\n\t\t// EPERM means it exists but we lack permission — still alive.\n\t\treturn (err as NodeJS.ErrnoException).code === \"EPERM\";\n\t}\n}\n\nfunction signalTree(pid: number, signal: NodeJS.Signals): boolean {\n\ttry {\n\t\tprocess.kill(-pid, signal);\n\t\treturn true;\n\t} catch {\n\t\ttry {\n\t\t\tprocess.kill(pid, signal);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\nexport interface KillTreeOptions {\n\t/** How long to wait for SIGTERM to work before SIGKILL. Default 5000ms. */\n\tgraceMs?: number;\n}\n\n/** Graceful tree kill: SIGTERM → poll liveness → SIGKILL. */\nexport async function killTree(pid: number, opts?: KillTreeOptions): Promise<\"already_dead\" | \"terminated\" | \"killed\"> {\n\tif (process.platform === \"win32\") {\n\t\tif (!isProcessAlive(pid)) return \"already_dead\";\n\t\tkillTreeNow(pid);\n\t\treturn \"killed\";\n\t}\n\tif (!isProcessAlive(pid)) return \"already_dead\";\n\tsignalTree(pid, \"SIGTERM\");\n\tconst graceMs = opts?.graceMs ?? 5000;\n\tconst deadline = Date.now() + graceMs;\n\twhile (Date.now() < deadline) {\n\t\tif (!isProcessAlive(pid)) return \"terminated\";\n\t\tawait new Promise((resolve) => setTimeout(resolve, Math.min(50, graceMs)));\n\t}\n\tif (!isProcessAlive(pid)) return \"terminated\";\n\tsignalTree(pid, \"SIGKILL\");\n\treturn \"killed\";\n}\n\n/** Immediate tree kill (SIGKILL / taskkill). Port of coding-agent's killProcessTree. */\nexport function killTreeNow(pid: number): void {\n\tif (process.platform === \"win32\") {\n\t\ttry {\n\t\t\tspawn(\"taskkill\", [\"/F\", \"/T\", \"/PID\", String(pid)], { stdio: \"ignore\", detached: true, windowsHide: true });\n\t\t} catch {\n\t\t\t// Ignore errors if taskkill fails.\n\t\t}\n\t} else {\n\t\tsignalTree(pid, \"SIGKILL\");\n\t}\n}\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic auto-retry driver.
|
|
3
|
+
*
|
|
4
|
+
* Owns the retry attempt counter and the abortable backoff for one agent run. It reads the
|
|
5
|
+
* failure verdict from {@link classifyFailure} (fed a host-computed context-overflow flag), so
|
|
6
|
+
* billing/auth are terminal and overflow routes to compaction — never to a pointless retry.
|
|
7
|
+
* The controller only ever touches `agent.state.messages` (it drops the trailing assistant error
|
|
8
|
+
* before retrying); durable history stays the host session's responsibility.
|
|
9
|
+
*
|
|
10
|
+
* Ported from AgentSession._prepareRetry / _isRetryableError so the exact event ordering and
|
|
11
|
+
* exhaustion semantics carry over: the retry window is marked active (isRetrying) before the
|
|
12
|
+
* start event fires, so prompts arriving inside start handlers queue as steering instead of
|
|
13
|
+
* racing the retry continuation.
|
|
14
|
+
*/
|
|
15
|
+
import { type AssistantMessage } from "@caupulican/pi-ai";
|
|
16
|
+
import type { AgentMessage } from "../types.ts";
|
|
17
|
+
import { type RetryPolicy } from "./retry.ts";
|
|
18
|
+
/** The slice of an Agent the retry driver reads and mutates: just the live transcript. */
|
|
19
|
+
export interface RetryAgent {
|
|
20
|
+
readonly state: {
|
|
21
|
+
messages: AgentMessage[];
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface RetryStartInfo {
|
|
25
|
+
attempt: number;
|
|
26
|
+
maxAttempts: number;
|
|
27
|
+
delayMs: number;
|
|
28
|
+
errorMessage: string;
|
|
29
|
+
}
|
|
30
|
+
export interface RetryEndInfo {
|
|
31
|
+
success: boolean;
|
|
32
|
+
attempt: number;
|
|
33
|
+
finalError?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface RetryEvents {
|
|
36
|
+
onRetryStart(info: RetryStartInfo): void;
|
|
37
|
+
onRetryEnd(info: RetryEndInfo): void;
|
|
38
|
+
}
|
|
39
|
+
/** Runtime retry policy: the backoff shape plus the on/off switch the host resolves per call. */
|
|
40
|
+
export type RetryControllerPolicy = RetryPolicy & {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
};
|
|
43
|
+
export declare class RetryController {
|
|
44
|
+
private _attempt;
|
|
45
|
+
private _abortController;
|
|
46
|
+
private readonly agent;
|
|
47
|
+
private readonly getPolicy;
|
|
48
|
+
private readonly events;
|
|
49
|
+
private readonly getContextWindow;
|
|
50
|
+
constructor(agent: RetryAgent, getPolicy: () => RetryControllerPolicy, events: RetryEvents, getContextWindow: () => number);
|
|
51
|
+
/** Completed retry attempts for the current run (0 when not retrying). */
|
|
52
|
+
get attempt(): number;
|
|
53
|
+
/** True from the instant onRetryStart fires until the backoff sleep resolves or is aborted. */
|
|
54
|
+
get isRetrying(): boolean;
|
|
55
|
+
/** Clear the attempt counter — the host calls this after a successful turn or a final failure. */
|
|
56
|
+
reset(): void;
|
|
57
|
+
/** Cancel an in-progress backoff; the pending prepareRetry resolves false. */
|
|
58
|
+
abort(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Classify `message`; if it is retryable and attempts remain, drop the trailing assistant
|
|
61
|
+
* error from agent state, emit the start event, and wait out the backoff (abortable).
|
|
62
|
+
* @returns true if the caller should continue the agent, false otherwise.
|
|
63
|
+
*/
|
|
64
|
+
prepareRetry(message: AssistantMessage): Promise<boolean>;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=retry-controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry-controller.d.ts","sourceRoot":"","sources":["../../src/reliability/retry-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,KAAK,gBAAgB,EAAqB,MAAM,mBAAmB,CAAC;AAC7E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,EAAuB,KAAK,WAAW,EAAkB,MAAM,YAAY,CAAC;AAEnF,0FAA0F;AAC1F,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC;CAC7C;AAED,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC3B,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAAC;CACrC;AAED,iGAAiG;AACjG,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAEvE,qBAAa,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA8B;IACxD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAe;IAEhD,YACC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,qBAAqB,EACtC,MAAM,EAAE,WAAW,EACnB,gBAAgB,EAAE,MAAM,MAAM,EAM9B;IAED,0EAA0E;IAC1E,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,+FAA+F;IAC/F,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,oGAAkG;IAClG,KAAK,IAAI,IAAI,CAEZ;IAED,8EAA8E;IAC9E,KAAK,IAAI,IAAI,CAEZ;IAED;;;;OAIG;IACG,YAAY,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAwD9D;CACD","sourcesContent":["/**\n * Host-agnostic auto-retry driver.\n *\n * Owns the retry attempt counter and the abortable backoff for one agent run. It reads the\n * failure verdict from {@link classifyFailure} (fed a host-computed context-overflow flag), so\n * billing/auth are terminal and overflow routes to compaction — never to a pointless retry.\n * The controller only ever touches `agent.state.messages` (it drops the trailing assistant error\n * before retrying); durable history stays the host session's responsibility.\n *\n * Ported from AgentSession._prepareRetry / _isRetryableError so the exact event ordering and\n * exhaustion semantics carry over: the retry window is marked active (isRetrying) before the\n * start event fires, so prompts arriving inside start handlers queue as steering instead of\n * racing the retry continuation.\n */\n\nimport { type AssistantMessage, isContextOverflow } from \"@caupulican/pi-ai\";\nimport type { AgentMessage } from \"../types.ts\";\nimport { classifyFailure } from \"./classifier.ts\";\nimport { computeRetryDelayMs, type RetryPolicy, sleepAbortable } from \"./retry.ts\";\n\n/** The slice of an Agent the retry driver reads and mutates: just the live transcript. */\nexport interface RetryAgent {\n\treadonly state: { messages: AgentMessage[] };\n}\n\nexport interface RetryStartInfo {\n\tattempt: number;\n\tmaxAttempts: number;\n\tdelayMs: number;\n\terrorMessage: string;\n}\n\nexport interface RetryEndInfo {\n\tsuccess: boolean;\n\tattempt: number;\n\tfinalError?: string;\n}\n\nexport interface RetryEvents {\n\tonRetryStart(info: RetryStartInfo): void;\n\tonRetryEnd(info: RetryEndInfo): void;\n}\n\n/** Runtime retry policy: the backoff shape plus the on/off switch the host resolves per call. */\nexport type RetryControllerPolicy = RetryPolicy & { enabled: boolean };\n\nexport class RetryController {\n\tprivate _attempt = 0;\n\tprivate _abortController: AbortController | undefined;\n\tprivate readonly agent: RetryAgent;\n\tprivate readonly getPolicy: () => RetryControllerPolicy;\n\tprivate readonly events: RetryEvents;\n\tprivate readonly getContextWindow: () => number;\n\n\tconstructor(\n\t\tagent: RetryAgent,\n\t\tgetPolicy: () => RetryControllerPolicy,\n\t\tevents: RetryEvents,\n\t\tgetContextWindow: () => number,\n\t) {\n\t\tthis.agent = agent;\n\t\tthis.getPolicy = getPolicy;\n\t\tthis.events = events;\n\t\tthis.getContextWindow = getContextWindow;\n\t}\n\n\t/** Completed retry attempts for the current run (0 when not retrying). */\n\tget attempt(): number {\n\t\treturn this._attempt;\n\t}\n\n\t/** True from the instant onRetryStart fires until the backoff sleep resolves or is aborted. */\n\tget isRetrying(): boolean {\n\t\treturn this._abortController !== undefined;\n\t}\n\n\t/** Clear the attempt counter — the host calls this after a successful turn or a final failure. */\n\treset(): void {\n\t\tthis._attempt = 0;\n\t}\n\n\t/** Cancel an in-progress backoff; the pending prepareRetry resolves false. */\n\tabort(): void {\n\t\tthis._abortController?.abort();\n\t}\n\n\t/**\n\t * Classify `message`; if it is retryable and attempts remain, drop the trailing assistant\n\t * error from agent state, emit the start event, and wait out the backoff (abortable).\n\t * @returns true if the caller should continue the agent, false otherwise.\n\t */\n\tasync prepareRetry(message: AssistantMessage): Promise<boolean> {\n\t\tconst policy = this.getPolicy();\n\t\tif (!policy.enabled) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// The classifier is the single source of the retry verdict: context overflow (host-computed\n\t\t// from the live window) routes to compaction, billing/auth are terminal, transient failures retry.\n\t\tconst classified = classifyFailure({\n\t\t\tmessage: message.errorMessage ?? \"\",\n\t\t\tcontextOverflow: isContextOverflow(message, this.getContextWindow()),\n\t\t});\n\t\tif (!classified.retryable) {\n\t\t\treturn false;\n\t\t}\n\n\t\tthis._attempt++;\n\t\tif (this._attempt > policy.maxAttempts) {\n\t\t\t// Preserve the completed attempt count so the host can emit the final failure.\n\t\t\tthis._attempt--;\n\t\t\treturn false;\n\t\t}\n\n\t\tconst delayMs = computeRetryDelayMs(policy, this._attempt);\n\n\t\t// The retry window counts as active work from the instant listeners hear about it:\n\t\t// isRetrying must already be true inside onRetryStart handlers so prompts arriving there\n\t\t// queue as steering instead of racing the retry continuation.\n\t\tthis._abortController = new AbortController();\n\n\t\tthis.events.onRetryStart({\n\t\t\tattempt: this._attempt,\n\t\t\tmaxAttempts: policy.maxAttempts,\n\t\t\tdelayMs,\n\t\t\terrorMessage: message.errorMessage || \"Unknown error\",\n\t\t});\n\n\t\t// Remove the trailing assistant error from live agent state (the host session keeps it in history).\n\t\tconst messages = this.agent.state.messages;\n\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\tthis.agent.state.messages = messages.slice(0, -1);\n\t\t}\n\n\t\ttry {\n\t\t\tawait sleepAbortable(delayMs, this._abortController.signal);\n\t\t} catch {\n\t\t\t// Aborted mid-backoff: report the cancellation and reset so the next turn starts clean.\n\t\t\tconst attempt = this._attempt;\n\t\t\tthis._attempt = 0;\n\t\t\tthis.events.onRetryEnd({ success: false, attempt, finalError: \"Retry cancelled\" });\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tthis._abortController = undefined;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic auto-retry driver.
|
|
3
|
+
*
|
|
4
|
+
* Owns the retry attempt counter and the abortable backoff for one agent run. It reads the
|
|
5
|
+
* failure verdict from {@link classifyFailure} (fed a host-computed context-overflow flag), so
|
|
6
|
+
* billing/auth are terminal and overflow routes to compaction — never to a pointless retry.
|
|
7
|
+
* The controller only ever touches `agent.state.messages` (it drops the trailing assistant error
|
|
8
|
+
* before retrying); durable history stays the host session's responsibility.
|
|
9
|
+
*
|
|
10
|
+
* Ported from AgentSession._prepareRetry / _isRetryableError so the exact event ordering and
|
|
11
|
+
* exhaustion semantics carry over: the retry window is marked active (isRetrying) before the
|
|
12
|
+
* start event fires, so prompts arriving inside start handlers queue as steering instead of
|
|
13
|
+
* racing the retry continuation.
|
|
14
|
+
*/
|
|
15
|
+
import { isContextOverflow } from "@caupulican/pi-ai";
|
|
16
|
+
import { classifyFailure } from "./classifier.js";
|
|
17
|
+
import { computeRetryDelayMs, sleepAbortable } from "./retry.js";
|
|
18
|
+
export class RetryController {
|
|
19
|
+
_attempt = 0;
|
|
20
|
+
_abortController;
|
|
21
|
+
agent;
|
|
22
|
+
getPolicy;
|
|
23
|
+
events;
|
|
24
|
+
getContextWindow;
|
|
25
|
+
constructor(agent, getPolicy, events, getContextWindow) {
|
|
26
|
+
this.agent = agent;
|
|
27
|
+
this.getPolicy = getPolicy;
|
|
28
|
+
this.events = events;
|
|
29
|
+
this.getContextWindow = getContextWindow;
|
|
30
|
+
}
|
|
31
|
+
/** Completed retry attempts for the current run (0 when not retrying). */
|
|
32
|
+
get attempt() {
|
|
33
|
+
return this._attempt;
|
|
34
|
+
}
|
|
35
|
+
/** True from the instant onRetryStart fires until the backoff sleep resolves or is aborted. */
|
|
36
|
+
get isRetrying() {
|
|
37
|
+
return this._abortController !== undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Clear the attempt counter — the host calls this after a successful turn or a final failure. */
|
|
40
|
+
reset() {
|
|
41
|
+
this._attempt = 0;
|
|
42
|
+
}
|
|
43
|
+
/** Cancel an in-progress backoff; the pending prepareRetry resolves false. */
|
|
44
|
+
abort() {
|
|
45
|
+
this._abortController?.abort();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Classify `message`; if it is retryable and attempts remain, drop the trailing assistant
|
|
49
|
+
* error from agent state, emit the start event, and wait out the backoff (abortable).
|
|
50
|
+
* @returns true if the caller should continue the agent, false otherwise.
|
|
51
|
+
*/
|
|
52
|
+
async prepareRetry(message) {
|
|
53
|
+
const policy = this.getPolicy();
|
|
54
|
+
if (!policy.enabled) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// The classifier is the single source of the retry verdict: context overflow (host-computed
|
|
58
|
+
// from the live window) routes to compaction, billing/auth are terminal, transient failures retry.
|
|
59
|
+
const classified = classifyFailure({
|
|
60
|
+
message: message.errorMessage ?? "",
|
|
61
|
+
contextOverflow: isContextOverflow(message, this.getContextWindow()),
|
|
62
|
+
});
|
|
63
|
+
if (!classified.retryable) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
this._attempt++;
|
|
67
|
+
if (this._attempt > policy.maxAttempts) {
|
|
68
|
+
// Preserve the completed attempt count so the host can emit the final failure.
|
|
69
|
+
this._attempt--;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const delayMs = computeRetryDelayMs(policy, this._attempt);
|
|
73
|
+
// The retry window counts as active work from the instant listeners hear about it:
|
|
74
|
+
// isRetrying must already be true inside onRetryStart handlers so prompts arriving there
|
|
75
|
+
// queue as steering instead of racing the retry continuation.
|
|
76
|
+
this._abortController = new AbortController();
|
|
77
|
+
this.events.onRetryStart({
|
|
78
|
+
attempt: this._attempt,
|
|
79
|
+
maxAttempts: policy.maxAttempts,
|
|
80
|
+
delayMs,
|
|
81
|
+
errorMessage: message.errorMessage || "Unknown error",
|
|
82
|
+
});
|
|
83
|
+
// Remove the trailing assistant error from live agent state (the host session keeps it in history).
|
|
84
|
+
const messages = this.agent.state.messages;
|
|
85
|
+
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
86
|
+
this.agent.state.messages = messages.slice(0, -1);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await sleepAbortable(delayMs, this._abortController.signal);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Aborted mid-backoff: report the cancellation and reset so the next turn starts clean.
|
|
93
|
+
const attempt = this._attempt;
|
|
94
|
+
this._attempt = 0;
|
|
95
|
+
this.events.onRetryEnd({ success: false, attempt, finalError: "Retry cancelled" });
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
this._abortController = undefined;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=retry-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry-controller.js","sourceRoot":"","sources":["../../src/reliability/retry-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Host-agnostic auto-retry driver.\n *\n * Owns the retry attempt counter and the abortable backoff for one agent run. It reads the\n * failure verdict from {@link classifyFailure} (fed a host-computed context-overflow flag), so\n * billing/auth are terminal and overflow routes to compaction — never to a pointless retry.\n * The controller only ever touches `agent.state.messages` (it drops the trailing assistant error\n * before retrying); durable history stays the host session's responsibility.\n *\n * Ported from AgentSession._prepareRetry / _isRetryableError so the exact event ordering and\n * exhaustion semantics carry over: the retry window is marked active (isRetrying) before the\n * start event fires, so prompts arriving inside start handlers queue as steering instead of\n * racing the retry continuation.\n */\n\nimport { type AssistantMessage, isContextOverflow } from \"@caupulican/pi-ai\";\nimport type { AgentMessage } from \"../types.ts\";\nimport { classifyFailure } from \"./classifier.ts\";\nimport { computeRetryDelayMs, type RetryPolicy, sleepAbortable } from \"./retry.ts\";\n\n/** The slice of an Agent the retry driver reads and mutates: just the live transcript. */\nexport interface RetryAgent {\n\treadonly state: { messages: AgentMessage[] };\n}\n\nexport interface RetryStartInfo {\n\tattempt: number;\n\tmaxAttempts: number;\n\tdelayMs: number;\n\terrorMessage: string;\n}\n\nexport interface RetryEndInfo {\n\tsuccess: boolean;\n\tattempt: number;\n\tfinalError?: string;\n}\n\nexport interface RetryEvents {\n\tonRetryStart(info: RetryStartInfo): void;\n\tonRetryEnd(info: RetryEndInfo): void;\n}\n\n/** Runtime retry policy: the backoff shape plus the on/off switch the host resolves per call. */\nexport type RetryControllerPolicy = RetryPolicy & { enabled: boolean };\n\nexport class RetryController {\n\tprivate _attempt = 0;\n\tprivate _abortController: AbortController | undefined;\n\tprivate readonly agent: RetryAgent;\n\tprivate readonly getPolicy: () => RetryControllerPolicy;\n\tprivate readonly events: RetryEvents;\n\tprivate readonly getContextWindow: () => number;\n\n\tconstructor(\n\t\tagent: RetryAgent,\n\t\tgetPolicy: () => RetryControllerPolicy,\n\t\tevents: RetryEvents,\n\t\tgetContextWindow: () => number,\n\t) {\n\t\tthis.agent = agent;\n\t\tthis.getPolicy = getPolicy;\n\t\tthis.events = events;\n\t\tthis.getContextWindow = getContextWindow;\n\t}\n\n\t/** Completed retry attempts for the current run (0 when not retrying). */\n\tget attempt(): number {\n\t\treturn this._attempt;\n\t}\n\n\t/** True from the instant onRetryStart fires until the backoff sleep resolves or is aborted. */\n\tget isRetrying(): boolean {\n\t\treturn this._abortController !== undefined;\n\t}\n\n\t/** Clear the attempt counter — the host calls this after a successful turn or a final failure. */\n\treset(): void {\n\t\tthis._attempt = 0;\n\t}\n\n\t/** Cancel an in-progress backoff; the pending prepareRetry resolves false. */\n\tabort(): void {\n\t\tthis._abortController?.abort();\n\t}\n\n\t/**\n\t * Classify `message`; if it is retryable and attempts remain, drop the trailing assistant\n\t * error from agent state, emit the start event, and wait out the backoff (abortable).\n\t * @returns true if the caller should continue the agent, false otherwise.\n\t */\n\tasync prepareRetry(message: AssistantMessage): Promise<boolean> {\n\t\tconst policy = this.getPolicy();\n\t\tif (!policy.enabled) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// The classifier is the single source of the retry verdict: context overflow (host-computed\n\t\t// from the live window) routes to compaction, billing/auth are terminal, transient failures retry.\n\t\tconst classified = classifyFailure({\n\t\t\tmessage: message.errorMessage ?? \"\",\n\t\t\tcontextOverflow: isContextOverflow(message, this.getContextWindow()),\n\t\t});\n\t\tif (!classified.retryable) {\n\t\t\treturn false;\n\t\t}\n\n\t\tthis._attempt++;\n\t\tif (this._attempt > policy.maxAttempts) {\n\t\t\t// Preserve the completed attempt count so the host can emit the final failure.\n\t\t\tthis._attempt--;\n\t\t\treturn false;\n\t\t}\n\n\t\tconst delayMs = computeRetryDelayMs(policy, this._attempt);\n\n\t\t// The retry window counts as active work from the instant listeners hear about it:\n\t\t// isRetrying must already be true inside onRetryStart handlers so prompts arriving there\n\t\t// queue as steering instead of racing the retry continuation.\n\t\tthis._abortController = new AbortController();\n\n\t\tthis.events.onRetryStart({\n\t\t\tattempt: this._attempt,\n\t\t\tmaxAttempts: policy.maxAttempts,\n\t\t\tdelayMs,\n\t\t\terrorMessage: message.errorMessage || \"Unknown error\",\n\t\t});\n\n\t\t// Remove the trailing assistant error from live agent state (the host session keeps it in history).\n\t\tconst messages = this.agent.state.messages;\n\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\tthis.agent.state.messages = messages.slice(0, -1);\n\t\t}\n\n\t\ttry {\n\t\t\tawait sleepAbortable(delayMs, this._abortController.signal);\n\t\t} catch {\n\t\t\t// Aborted mid-backoff: report the cancellation and reset so the next turn starts clean.\n\t\t\tconst attempt = this._attempt;\n\t\t\tthis._attempt = 0;\n\t\t\tthis.events.onRetryEnd({ success: false, attempt, finalError: \"Retry cancelled\" });\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tthis._abortController = undefined;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Retry backoff policy. jitterRatio 0 reproduces legacy fixed 2s/4s/8s behavior. */
|
|
2
|
+
export interface RetryPolicy {
|
|
3
|
+
maxAttempts: number;
|
|
4
|
+
baseDelayMs: number;
|
|
5
|
+
maxDelayMs: number;
|
|
6
|
+
/** Fraction of the computed delay added as uniform random jitter (0..1). */
|
|
7
|
+
jitterRatio: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
|
|
10
|
+
export interface ComputeRetryDelayOptions {
|
|
11
|
+
/** Provider-suggested delay (e.g. from Retry-After); wins over backoff but is capped. */
|
|
12
|
+
retryAfterMs?: number;
|
|
13
|
+
/** Injectable RNG for deterministic tests. Defaults to Math.random. */
|
|
14
|
+
random?: () => number;
|
|
15
|
+
}
|
|
16
|
+
export declare function computeRetryDelayMs(policy: RetryPolicy, attempt: number, opts?: ComputeRetryDelayOptions): number;
|
|
17
|
+
/** Abortable sleep: resolves after ms, rejects with the abort reason if the signal fires. */
|
|
18
|
+
export declare function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void>;
|
|
19
|
+
//# sourceMappingURL=retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/reliability/retry.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,oBAAoB,EAAE,WAKlC,CAAC;AAEF,MAAM,WAAW,wBAAwB;IACxC,yFAAyF;IACzF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,wBAAwB,GAAG,MAAM,CAQjH;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB9E","sourcesContent":["/** Retry backoff policy. jitterRatio 0 reproduces legacy fixed 2s/4s/8s behavior. */\nexport interface RetryPolicy {\n\tmaxAttempts: number;\n\tbaseDelayMs: number;\n\tmaxDelayMs: number;\n\t/** Fraction of the computed delay added as uniform random jitter (0..1). */\n\tjitterRatio: number;\n}\n\nexport const DEFAULT_RETRY_POLICY: RetryPolicy = {\n\tmaxAttempts: 3,\n\tbaseDelayMs: 2000,\n\tmaxDelayMs: 120_000,\n\tjitterRatio: 0,\n};\n\nexport interface ComputeRetryDelayOptions {\n\t/** Provider-suggested delay (e.g. from Retry-After); wins over backoff but is capped. */\n\tretryAfterMs?: number;\n\t/** Injectable RNG for deterministic tests. Defaults to Math.random. */\n\trandom?: () => number;\n}\n\nexport function computeRetryDelayMs(policy: RetryPolicy, attempt: number, opts?: ComputeRetryDelayOptions): number {\n\tif (opts?.retryAfterMs !== undefined) {\n\t\treturn Math.min(opts.retryAfterMs, policy.maxDelayMs);\n\t}\n\tconst exponential = Math.min(policy.baseDelayMs * 2 ** (Math.max(1, attempt) - 1), policy.maxDelayMs);\n\tif (policy.jitterRatio <= 0) return exponential;\n\tconst random = opts?.random ?? Math.random;\n\treturn Math.round(exponential + policy.jitterRatio * exponential * random());\n}\n\n/** Abortable sleep: resolves after ms, rejects with the abort reason if the signal fires. */\nexport function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (signal?.aborted) {\n\t\t\treject(signal.reason instanceof Error ? signal.reason : new Error(\"Aborted\"));\n\t\t\treturn;\n\t\t}\n\t\tconst timer = setTimeout(() => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\t\tconst onAbort = () => {\n\t\t\tclearTimeout(timer);\n\t\t\treject(signal?.reason instanceof Error ? signal.reason : new Error(\"Aborted\"));\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const DEFAULT_RETRY_POLICY = {
|
|
2
|
+
maxAttempts: 3,
|
|
3
|
+
baseDelayMs: 2000,
|
|
4
|
+
maxDelayMs: 120_000,
|
|
5
|
+
jitterRatio: 0,
|
|
6
|
+
};
|
|
7
|
+
export function computeRetryDelayMs(policy, attempt, opts) {
|
|
8
|
+
if (opts?.retryAfterMs !== undefined) {
|
|
9
|
+
return Math.min(opts.retryAfterMs, policy.maxDelayMs);
|
|
10
|
+
}
|
|
11
|
+
const exponential = Math.min(policy.baseDelayMs * 2 ** (Math.max(1, attempt) - 1), policy.maxDelayMs);
|
|
12
|
+
if (policy.jitterRatio <= 0)
|
|
13
|
+
return exponential;
|
|
14
|
+
const random = opts?.random ?? Math.random;
|
|
15
|
+
return Math.round(exponential + policy.jitterRatio * exponential * random());
|
|
16
|
+
}
|
|
17
|
+
/** Abortable sleep: resolves after ms, rejects with the abort reason if the signal fires. */
|
|
18
|
+
export function sleepAbortable(ms, signal) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
if (signal?.aborted) {
|
|
21
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
signal?.removeEventListener("abort", onAbort);
|
|
26
|
+
resolve();
|
|
27
|
+
}, ms);
|
|
28
|
+
const onAbort = () => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
|
|
31
|
+
};
|
|
32
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/reliability/retry.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,MAAM,oBAAoB,GAAgB;IAChD,WAAW,EAAE,CAAC;IACd,WAAW,EAAE,IAAI;IACjB,UAAU,EAAE,OAAO;IACnB,WAAW,EAAE,CAAC;CACd,CAAC;AASF,MAAM,UAAU,mBAAmB,CAAC,MAAmB,EAAE,OAAe,EAAE,IAA+B,EAAU;IAClH,IAAI,IAAI,EAAE,YAAY,KAAK,SAAS,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IACvD,CAAC;IACD,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IACtG,IAAI,MAAM,CAAC,WAAW,IAAI,CAAC;QAAE,OAAO,WAAW,CAAC;IAChD,MAAM,MAAM,GAAG,IAAI,EAAE,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC;IAC3C,OAAO,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,MAAM,CAAC,WAAW,GAAG,WAAW,GAAG,MAAM,EAAE,CAAC,CAAC;AAAA,CAC7E;AAED,6FAA6F;AAC7F,MAAM,UAAU,cAAc,CAAC,EAAU,EAAE,MAAoB,EAAiB;IAC/E,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC;QACvC,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACrB,MAAM,CAAC,MAAM,CAAC,MAAM,YAAY,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;YAC9E,OAAO;QACR,CAAC;QACD,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC;YAC9B,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC9C,OAAO,EAAE,CAAC;QAAA,CACV,EAAE,EAAE,CAAC,CAAC;QACP,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;YACrB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,MAAM,CAAC,MAAM,EAAE,MAAM,YAAY,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;QAAA,CAC/E,CAAC;QACF,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAAA,CAC3D,CAAC,CAAC;AAAA,CACH","sourcesContent":["/** Retry backoff policy. jitterRatio 0 reproduces legacy fixed 2s/4s/8s behavior. */\nexport interface RetryPolicy {\n\tmaxAttempts: number;\n\tbaseDelayMs: number;\n\tmaxDelayMs: number;\n\t/** Fraction of the computed delay added as uniform random jitter (0..1). */\n\tjitterRatio: number;\n}\n\nexport const DEFAULT_RETRY_POLICY: RetryPolicy = {\n\tmaxAttempts: 3,\n\tbaseDelayMs: 2000,\n\tmaxDelayMs: 120_000,\n\tjitterRatio: 0,\n};\n\nexport interface ComputeRetryDelayOptions {\n\t/** Provider-suggested delay (e.g. from Retry-After); wins over backoff but is capped. */\n\tretryAfterMs?: number;\n\t/** Injectable RNG for deterministic tests. Defaults to Math.random. */\n\trandom?: () => number;\n}\n\nexport function computeRetryDelayMs(policy: RetryPolicy, attempt: number, opts?: ComputeRetryDelayOptions): number {\n\tif (opts?.retryAfterMs !== undefined) {\n\t\treturn Math.min(opts.retryAfterMs, policy.maxDelayMs);\n\t}\n\tconst exponential = Math.min(policy.baseDelayMs * 2 ** (Math.max(1, attempt) - 1), policy.maxDelayMs);\n\tif (policy.jitterRatio <= 0) return exponential;\n\tconst random = opts?.random ?? Math.random;\n\treturn Math.round(exponential + policy.jitterRatio * exponential * random());\n}\n\n/** Abortable sleep: resolves after ms, rejects with the abort reason if the signal fires. */\nexport function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (signal?.aborted) {\n\t\t\treject(signal.reason instanceof Error ? signal.reason : new Error(\"Aborted\"));\n\t\t\treturn;\n\t\t}\n\t\tconst timer = setTimeout(() => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\t\tconst onAbort = () => {\n\t\t\tclearTimeout(timer);\n\t\t\treject(signal?.reason instanceof Error ? signal.reason : new Error(\"Aborted\"));\n\t\t};\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n"]}
|