openhermes 4.3.0 → 4.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CONTEXT.md +10 -1
  2. package/README.md +54 -42
  3. package/bootstrap.ts +396 -142
  4. package/harness/agents/oh-browser.md +97 -0
  5. package/harness/agents/oh-builder.md +78 -0
  6. package/harness/agents/oh-facade.md +75 -0
  7. package/harness/agents/oh-fusion.md +45 -0
  8. package/harness/agents/oh-gauntlet.md +71 -0
  9. package/harness/agents/oh-grill.md +71 -0
  10. package/harness/agents/oh-investigate.md +60 -0
  11. package/harness/agents/oh-manifest.md +95 -0
  12. package/harness/agents/oh-plan-review.md +40 -0
  13. package/harness/agents/oh-planner.md +50 -0
  14. package/harness/agents/oh-refactor.md +37 -0
  15. package/harness/agents/oh-retro.md +46 -0
  16. package/harness/agents/oh-review.md +85 -0
  17. package/harness/agents/oh-security.md +83 -0
  18. package/harness/agents/oh-ship.md +76 -0
  19. package/harness/agents/oh-skill-craft.md +38 -0
  20. package/harness/agents/openhermes.md +28 -73
  21. package/harness/codex/AUTOPILOT.md +235 -87
  22. package/harness/codex/CHARTER.md +80 -0
  23. package/harness/instructions/SHELL.md +76 -0
  24. package/harness/lib/background/background.test.ts +197 -0
  25. package/harness/lib/background/index.ts +7 -0
  26. package/harness/lib/background/interfaces.ts +31 -0
  27. package/harness/lib/background/manager.ts +320 -0
  28. package/harness/lib/composer/compose.test.ts +168 -0
  29. package/harness/lib/composer/compose.ts +65 -0
  30. package/harness/lib/composer/fragments/01-identity.md +1 -0
  31. package/harness/lib/composer/fragments/02-delegation.md +6 -0
  32. package/harness/lib/composer/fragments/03-permissions.md +13 -0
  33. package/harness/lib/composer/fragments/04-task-flow.md +15 -0
  34. package/harness/lib/composer/fragments/05-confidence.md +5 -0
  35. package/harness/lib/composer/fragments/06-parallelization.md +17 -0
  36. package/harness/lib/composer/fragments/07-shell.md +41 -0
  37. package/harness/lib/composer/fragments/08-routing.md +8 -0
  38. package/harness/lib/composer/fragments/09-guardrails.md +12 -0
  39. package/harness/lib/composer/index.ts +1 -0
  40. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +70 -0
  41. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +59 -0
  42. package/harness/lib/hooks/builtins/error-recovery-hook.ts +107 -0
  43. package/harness/lib/hooks/builtins/memory-sync-hook.ts +73 -0
  44. package/harness/lib/hooks/builtins/plan-check-hook.ts +43 -0
  45. package/harness/lib/hooks/builtins/route-tracking-hook.ts +147 -0
  46. package/harness/lib/hooks/builtins/sanity-check-hook.ts +52 -0
  47. package/harness/lib/hooks/builtins/shell-detect-hook.ts +96 -0
  48. package/harness/lib/hooks/hooks.test.ts +1016 -0
  49. package/harness/lib/hooks/index.ts +30 -0
  50. package/harness/lib/hooks/registry.ts +416 -0
  51. package/harness/lib/hooks/types.ts +71 -0
  52. package/harness/lib/memory/index.ts +18 -0
  53. package/harness/lib/memory/interfaces.ts +53 -0
  54. package/harness/lib/memory/memory-manager.ts +205 -0
  55. package/harness/lib/memory/memory.test.ts +491 -0
  56. package/harness/lib/memory/plan-store.ts +366 -0
  57. package/harness/lib/recovery/handler.ts +243 -0
  58. package/harness/lib/recovery/index.ts +14 -0
  59. package/harness/lib/recovery/interfaces.ts +48 -0
  60. package/harness/lib/recovery/patterns.ts +149 -0
  61. package/harness/lib/recovery/recovery.test.ts +312 -0
  62. package/harness/lib/sanity/anomaly-tracker.ts +127 -0
  63. package/harness/lib/sanity/checker.ts +178 -0
  64. package/harness/lib/sanity/index.ts +13 -0
  65. package/harness/lib/sanity/interfaces.ts +24 -0
  66. package/harness/lib/sanity/sanity.test.ts +472 -0
  67. package/harness/lib/sync/file-watcher.ts +174 -0
  68. package/harness/lib/sync/index.ts +11 -0
  69. package/harness/lib/sync/interfaces.ts +27 -0
  70. package/harness/lib/sync/plan-sync.ts +536 -0
  71. package/harness/lib/sync/sync.test.ts +832 -0
  72. package/harness/skills/oh-ascii/DEEP.md +292 -0
  73. package/harness/skills/oh-ascii/SKILL.md +31 -0
  74. package/harness/skills/oh-ascii/scripts/check_ascii_alignment.py +596 -0
  75. package/harness/skills/oh-browser/DEEP.md +54 -0
  76. package/harness/skills/oh-browser/SKILL.md +30 -0
  77. package/harness/skills/oh-builder/DEEP.md +63 -0
  78. package/harness/skills/oh-builder/SKILL.md +12 -90
  79. package/harness/skills/oh-expert/DEEP.md +85 -0
  80. package/harness/skills/oh-expert/SKILL.md +13 -106
  81. package/harness/skills/oh-facade/DEEP.md +182 -0
  82. package/harness/skills/oh-facade/SKILL.md +15 -279
  83. package/harness/skills/oh-freeze/DEEP.md +18 -0
  84. package/harness/skills/oh-freeze/SKILL.md +10 -19
  85. package/harness/skills/oh-full-output/DEEP.md +25 -0
  86. package/harness/skills/oh-full-output/SKILL.md +12 -65
  87. package/harness/skills/oh-fusion/DEEP.md +120 -0
  88. package/harness/skills/oh-fusion/SKILL.md +17 -295
  89. package/harness/skills/oh-gauntlet/DEEP.md +77 -0
  90. package/harness/skills/oh-gauntlet/SKILL.md +13 -105
  91. package/harness/skills/oh-grill/DEEP.md +51 -0
  92. package/harness/skills/oh-grill/SKILL.md +12 -63
  93. package/harness/skills/oh-guard/DEEP.md +19 -0
  94. package/harness/skills/oh-guard/SKILL.md +10 -24
  95. package/harness/skills/oh-handoff/DEEP.md +48 -0
  96. package/harness/skills/oh-handoff/SKILL.md +13 -23
  97. package/harness/skills/oh-health/DEEP.md +74 -0
  98. package/harness/skills/oh-health/SKILL.md +13 -76
  99. package/harness/skills/oh-init/DEEP.md +85 -0
  100. package/harness/skills/oh-init/SKILL.md +13 -127
  101. package/harness/skills/oh-investigate/DEEP.md +171 -0
  102. package/harness/skills/oh-investigate/SKILL.md +13 -66
  103. package/harness/skills/oh-issue/DEEP.md +21 -0
  104. package/harness/skills/oh-issue/SKILL.md +11 -27
  105. package/harness/skills/oh-manifest/DEEP.md +92 -0
  106. package/harness/skills/oh-manifest/SKILL.md +12 -109
  107. package/harness/skills/oh-plan-review/DEEP.md +90 -0
  108. package/harness/skills/oh-plan-review/SKILL.md +13 -115
  109. package/harness/skills/oh-planner/DEEP.md +172 -0
  110. package/harness/skills/oh-planner/SKILL.md +12 -149
  111. package/harness/skills/oh-prd/DEEP.md +45 -0
  112. package/harness/skills/oh-prd/SKILL.md +10 -26
  113. package/harness/skills/oh-refactor/DEEP.md +122 -0
  114. package/harness/skills/oh-refactor/SKILL.md +17 -410
  115. package/harness/skills/oh-retro/DEEP.md +26 -0
  116. package/harness/skills/oh-retro/SKILL.md +12 -24
  117. package/harness/skills/oh-review/DEEP.md +87 -0
  118. package/harness/skills/oh-review/SKILL.md +11 -97
  119. package/harness/skills/oh-security/DEEP.md +83 -0
  120. package/harness/skills/oh-security/SKILL.md +14 -96
  121. package/harness/skills/oh-ship/DEEP.md +141 -0
  122. package/harness/skills/oh-ship/SKILL.md +14 -32
  123. package/harness/skills/oh-skill-craft/DEEP.md +369 -0
  124. package/harness/skills/oh-skill-craft/SKILL.md +13 -177
  125. package/harness/skills/oh-skills-link/DEEP.md +16 -0
  126. package/harness/skills/oh-skills-link/SKILL.md +10 -20
  127. package/harness/skills/oh-skills-list/DEEP.md +20 -0
  128. package/harness/skills/oh-skills-list/SKILL.md +9 -22
  129. package/harness/skills/oh-triage/DEEP.md +23 -0
  130. package/harness/skills/oh-triage/SKILL.md +8 -24
  131. package/harness/skills/oh-worktree/DEEP.md +169 -0
  132. package/harness/skills/oh-worktree/SKILL.md +32 -0
  133. package/lib/harness-resolver.ts +8 -10
  134. package/package.json +7 -5
  135. package/tsconfig.json +1 -1
  136. package/harness/codex/CONSTITUTION.md +0 -73
  137. package/harness/codex/ROUTING.md +0 -92
  138. package/harness/commands/oh-doctor.md +0 -26
  139. package/harness/commands/oh-log.md +0 -18
  140. package/harness/instructions/RUNTIME.md +0 -30
  141. package/harness/skills/oh-caveman/SKILL.md +0 -42
  142. package/harness/skills/oh-learn/SKILL.md +0 -101
  143. package/lib/logger.ts +0 -75
@@ -0,0 +1,149 @@
1
+ // Categorized error patterns with recovery action generators.
2
+ // Each pattern is a RegExp matched against error messages.
3
+ // The first match wins. If none match, the handler escalates.
4
+
5
+ import type { ErrorCategory, ErrorContext, RecoveryAction } from "./interfaces.ts";
6
+
7
+ export interface ErrorPattern {
8
+ pattern: RegExp;
9
+ category: ErrorCategory;
10
+ getAction: (ctx: ErrorContext) => RecoveryAction;
11
+ }
12
+
13
+ /**
14
+ * Default backoff delays (ms) for retries: [initial, second, third]
15
+ */
16
+ const RATE_LIMIT_BACKOFF = [1_000, 2_000, 4_000] as const;
17
+ const NETWORK_BACKOFF = [500, 1_000, 2_000] as const;
18
+
19
+ export const PATTERNS: ErrorPattern[] = [
20
+ // ── rate_limit ─────────────────────────────────────────────────────
21
+ {
22
+ pattern: /rate.?limit|too.?many.?requests|429/i,
23
+ category: "rate_limit",
24
+ getAction: (ctx: ErrorContext): RecoveryAction => {
25
+ const delay = RATE_LIMIT_BACKOFF[Math.min(ctx.attempt, RATE_LIMIT_BACKOFF.length - 1)];
26
+ return {
27
+ type: "retry",
28
+ delay,
29
+ maxAttempts: 3,
30
+ reason: `Rate limited (attempt ${ctx.attempt + 1}): backing off ${delay}ms`,
31
+ modifyPrompt: undefined,
32
+ };
33
+ },
34
+ },
35
+
36
+ // ── context_overflow ──────────────────────────────────────────────
37
+ {
38
+ pattern: /context.?length|token.?limit|maximum.?context/i,
39
+ category: "context_overflow",
40
+ getAction: (): RecoveryAction => ({
41
+ type: "compact",
42
+ maxAttempts: 2,
43
+ reason: "Context overflow detected: compacting before retry",
44
+ modifyPrompt:
45
+ "--- COMPACTED: Previous context exceeded token limits. Continue from here, preserving only essential state. ---",
46
+ }),
47
+ },
48
+
49
+ // ── network ───────────────────────────────────────────────────────
50
+ {
51
+ pattern: /ECONNREFUSED|ETIMEDOUT|network|fetch.?failed/i,
52
+ category: "network",
53
+ getAction: (ctx: ErrorContext): RecoveryAction => {
54
+ const delay = NETWORK_BACKOFF[Math.min(ctx.attempt, NETWORK_BACKOFF.length - 1)];
55
+ return {
56
+ type: "retry",
57
+ delay,
58
+ maxAttempts: 3,
59
+ reason: `Network error (attempt ${ctx.attempt + 1}): retrying in ${delay}ms`,
60
+ };
61
+ },
62
+ },
63
+
64
+ // ── session ───────────────────────────────────────────────────────
65
+ {
66
+ pattern: /session.?not.?found|session.?expired/i,
67
+ category: "session",
68
+ getAction: (): RecoveryAction => ({
69
+ type: "abort",
70
+ reason: "Session not found or expired — cannot recover",
71
+ }),
72
+ },
73
+
74
+ // ── tool_error ────────────────────────────────────────────────────
75
+ {
76
+ pattern: /tool.?not.?found|unknown.?tool/i,
77
+ category: "tool_error",
78
+ getAction: (): RecoveryAction => ({
79
+ type: "escalate",
80
+ reason: "Unknown or missing tool — orchestrator must decide",
81
+ }),
82
+ },
83
+
84
+ // ── parse_error ───────────────────────────────────────────────────
85
+ {
86
+ pattern: /parse.?error|invalid.?json|syntax.?error/i,
87
+ category: "parse_error",
88
+ getAction: (ctx: ErrorContext): RecoveryAction => ({
89
+ type: "retry",
90
+ delay: 0,
91
+ maxAttempts: 2,
92
+ reason: `Parse error on attempt ${ctx.attempt + 1}: retrying without modification`,
93
+ }),
94
+ },
95
+
96
+ // ── gibberish ─────────────────────────────────────────────────────
97
+ // Broad heuristic on output quality — catches gibberish, keyboard mash,
98
+ // repeated single characters, and long non-alphabetic sequences.
99
+ {
100
+ pattern: /gibberish|nonsens|unintelligible|asdfgh|qwerty|xxxxx|sdfsdf|[^a-z\s]{10,}|(.)\1{4,}/i,
101
+ category: "gibberish",
102
+ getAction: (): RecoveryAction => ({
103
+ type: "retry",
104
+ delay: 0,
105
+ maxAttempts: 2,
106
+ reason: "Gibberish output detected — retrying with clean context",
107
+ modifyPrompt:
108
+ "--- RECOVERY: Previous response was incoherent. Restart with only essential instructions. ---",
109
+ }),
110
+ },
111
+
112
+ // ── lsp_diagnostic ────────────────────────────────────────────────
113
+ {
114
+ pattern: /lsp.?diagnostic|tsc.?error|eslint.?error/i,
115
+ category: "lsp_diagnostic",
116
+ getAction: (): RecoveryAction => ({
117
+ type: "retry",
118
+ delay: 0,
119
+ maxAttempts: 2,
120
+ reason: "LSP diagnostic error — retrying with diagnostics in context",
121
+ modifyPrompt:
122
+ "--- RECOVERY: The following LSP diagnostics were detected in the last attempt. Fix them before continuing. ---",
123
+ }),
124
+ },
125
+
126
+ // ── timeout ───────────────────────────────────────────────────────
127
+ {
128
+ pattern: /execution.?timed.?out|timeout/i,
129
+ category: "timeout",
130
+ getAction: (): RecoveryAction => ({
131
+ type: "retry",
132
+ delay: 0,
133
+ maxAttempts: 2,
134
+ reason: "Execution timed out — retrying with task breakdown hint",
135
+ modifyPrompt:
136
+ "--- RECOVERY: Previous execution timed out. Break the task into smaller steps. ---",
137
+ }),
138
+ },
139
+ ];
140
+
141
+ /**
142
+ * Default escalation action when no pattern matches.
143
+ */
144
+ export function escalateAction(ctx: ErrorContext): RecoveryAction {
145
+ return {
146
+ type: "escalate",
147
+ reason: `Unrecognized error "${ctx.error.message}" — escalating to orchestrator`,
148
+ };
149
+ }
@@ -0,0 +1,312 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { RecoveryHandler } from "./handler.ts";
4
+ import type { ErrorContext, RecoveryAction } from "./interfaces.ts";
5
+
6
+ /**
7
+ * Helper to build an ErrorContext.
8
+ */
9
+ function ctx(
10
+ sessionId: string,
11
+ message: string,
12
+ attempt = 0,
13
+ agent?: string,
14
+ ): ErrorContext {
15
+ return {
16
+ sessionId,
17
+ error: new Error(message),
18
+ attempt,
19
+ timestamp: Date.now(),
20
+ agent,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Check that an action has the expected type & reason substring.
26
+ */
27
+ function assertAction(
28
+ action: RecoveryAction,
29
+ type: string,
30
+ reasonSubstring?: string,
31
+ ): void {
32
+ assert.equal(action.type, type, `expected type ${type}, got ${action.type}`);
33
+ if (reasonSubstring) {
34
+ assert.ok(
35
+ action.reason.toLowerCase().includes(reasonSubstring.toLowerCase()),
36
+ `expected reason to include "${reasonSubstring}", got "${action.reason}"`,
37
+ );
38
+ }
39
+ }
40
+
41
+ // ── Helpers ──────────────────────────────────────────────────────────
42
+
43
+ describe("RecoveryHandler — pattern classification", () => {
44
+ let handler: RecoveryHandler;
45
+
46
+ before(() => {
47
+ handler = RecoveryHandler.getInstance();
48
+ handler.reset();
49
+ });
50
+
51
+ after(() => {
52
+ handler.reset();
53
+ });
54
+
55
+ // ── 9 category tests ─────────────────────────────────────────────
56
+
57
+ it("classifies rate_limit errors → retry with backoff", () => {
58
+ const action = handler.handleError(ctx("s1", "rate limit exceeded", 0));
59
+ assertAction(action, "retry", "rate");
60
+ assert.ok(action.delay! >= 1_000, `expected delay >= 1000, got ${action.delay}`);
61
+
62
+ // Second attempt gets longer backoff
63
+ const action2 = handler.handleError(ctx("s1", "429 Too Many Requests", 1));
64
+ assertAction(action2, "retry", "rate");
65
+ assert.ok(action2.delay! >= 2_000, `expected delay >= 2000, got ${action2.delay}`);
66
+ });
67
+
68
+ it("classifies context_overflow errors → compact", () => {
69
+ const action = handler.handleError(ctx("s1", "context length exceeded", 0));
70
+ assertAction(action, "compact", "context");
71
+ assert.ok(action.modifyPrompt, "compact action should include modifyPrompt");
72
+ });
73
+
74
+ it("classifies network errors → retry with backoff", () => {
75
+ const action = handler.handleError(ctx("s1", "ECONNREFUSED", 0));
76
+ assertAction(action, "retry", "network");
77
+ assert.ok(action.delay! >= 500, `expected delay >= 500, got ${action.delay}`);
78
+
79
+ const action2 = handler.handleError(ctx("s1", "fetch failed", 1));
80
+ assertAction(action2, "retry", "network");
81
+ });
82
+
83
+ it("classifies session errors → abort", () => {
84
+ const action = handler.handleError(ctx("s1", "session not found", 0));
85
+ assertAction(action, "abort", "session");
86
+ });
87
+
88
+ it("classifies tool_error → escalate", () => {
89
+ const action = handler.handleError(ctx("s1", "unknown tool: foo", 0));
90
+ assertAction(action, "escalate", "tool");
91
+ });
92
+
93
+ it("classifies parse_error → retry (max 2)", () => {
94
+ const action = handler.handleError(ctx("s1", "parse error at line 42", 0));
95
+ assertAction(action, "retry", "parse");
96
+ assert.equal(action.maxAttempts, 2);
97
+ });
98
+
99
+ it("classifies gibberish (explicit) → retry with clean context", () => {
100
+ // Gibberish pattern now matches "gibberish", "nonsens", keyboard mash,
101
+ // and other low-quality output patterns in error messages.
102
+ const action = handler.handleError(ctx("s1", "gibberish output detected", 0));
103
+ assertAction(action, "retry", "gibberish");
104
+ assert.ok(action.modifyPrompt, "gibberish action should include modifyPrompt");
105
+ });
106
+
107
+ it("classifies lsp_diagnostic → retry with diagnostic prompt", () => {
108
+ const action = handler.handleError(ctx("s1", "tsc error: Type 'X' not assignable", 0));
109
+ assertAction(action, "retry", "lsp");
110
+ assert.ok(action.modifyPrompt, "lsp action should include modifyPrompt");
111
+ });
112
+
113
+ it("classifies timeout → retry with breakdown hint", () => {
114
+ const action = handler.handleError(ctx("s1", "execution timed out", 0));
115
+ assertAction(action, "retry", "timed out");
116
+ assert.ok(action.modifyPrompt, "timeout action should include modifyPrompt");
117
+ assert.equal(action.maxAttempts, 2);
118
+ });
119
+
120
+ // ── Unknown error → escalate ──────────────────────────────────────
121
+
122
+ it("unknown error pattern → escalate", () => {
123
+ const action = handler.handleError(ctx("s1", "some weird error nobody expected", 0));
124
+ assertAction(action, "escalate", "unrecognized");
125
+ });
126
+
127
+ // ── Stats tracking ────────────────────────────────────────────────
128
+
129
+ it("getStats() reflects classified errors", () => {
130
+ handler.reset();
131
+
132
+ handler.handleError(ctx("s1", "rate limit", 0));
133
+ handler.handleError(ctx("s1", "ECONNREFUSED", 0));
134
+ handler.handleError(ctx("s1", "context length", 0));
135
+ handler.handleError(ctx("s1", "session not found", 0));
136
+ handler.handleError(ctx("s1", "unknown tool", 0));
137
+ handler.handleError(ctx("s1", "parse error", 0));
138
+
139
+ const stats = handler.getStats();
140
+ assert.equal(stats.totalRecoveries, 6);
141
+ assert.equal(stats.byCategory.rate_limit, 1);
142
+ assert.equal(stats.byCategory.network, 1);
143
+ assert.equal(stats.byCategory.context_overflow, 1);
144
+ assert.equal(stats.byCategory.session, 1);
145
+ assert.equal(stats.byCategory.tool_error, 1);
146
+ assert.equal(stats.byCategory.parse_error, 1);
147
+ // Unclassified categories should be 0
148
+ assert.equal(stats.byCategory.gibberish, 0);
149
+ assert.equal(stats.byCategory.lsp_diagnostic, 0);
150
+ assert.equal(stats.byCategory.timeout, 0);
151
+ });
152
+
153
+ it("getStats() tracks action types", () => {
154
+ handler.reset();
155
+
156
+ handler.handleError(ctx("s1", "rate limit", 0)); // retry
157
+ handler.handleError(ctx("s1", "session expired", 0)); // abort
158
+ handler.handleError(ctx("s1", "unknown tool", 0)); // escalate
159
+ handler.handleError(ctx("s1", "context length", 0)); // compact
160
+
161
+ const stats = handler.getStats();
162
+ assert.equal(stats.byAction.retry, 1);
163
+ assert.equal(stats.byAction.abort, 1);
164
+ assert.equal(stats.byAction.escalate, 1);
165
+ assert.equal(stats.byAction.compact, 1);
166
+ });
167
+
168
+ // ── getHistory ──────────────────────────────────────────────────
169
+
170
+ it("getHistory() returns records most recent first", () => {
171
+ handler.reset();
172
+
173
+ handler.handleError(ctx("s2", "rate limit", 0));
174
+ handler.handleError(ctx("s2", "timeout", 0));
175
+
176
+ const history = handler.getHistory();
177
+ assert.equal(history.length, 2);
178
+ assert.ok(history[0].timestamp >= history[1].timestamp);
179
+ });
180
+
181
+ it("getHistory(limit) respects limit", () => {
182
+ handler.reset();
183
+
184
+ handler.handleError(ctx("s3", "rate limit", 0));
185
+ handler.handleError(ctx("s3", "timeout", 0));
186
+ handler.handleError(ctx("s3", "ECONNREFUSED", 0));
187
+
188
+ const limited = handler.getHistory(2);
189
+ assert.equal(limited.length, 2);
190
+ });
191
+
192
+ // ── clearSession ────────────────────────────────────────────────
193
+
194
+ it("clearSession() removes records for a session", () => {
195
+ handler.reset();
196
+
197
+ handler.handleError(ctx("s_a", "rate limit", 0));
198
+ handler.handleError(ctx("s_b", "timeout", 0));
199
+ handler.handleError(ctx("s_a", "ECONNREFUSED", 0));
200
+
201
+ assert.equal(handler.getHistory().length, 3);
202
+
203
+ handler.clearSession("s_a");
204
+ const remaining = handler.getHistory();
205
+ assert.equal(remaining.length, 1);
206
+ assert.equal(remaining[0].context.sessionId, "s_b");
207
+ });
208
+ });
209
+
210
+ // ── withRecovery integration ──────────────────────────────────────────
211
+
212
+ describe("RecoveryHandler — withRecovery", () => {
213
+ let handler: RecoveryHandler;
214
+
215
+ before(() => {
216
+ handler = RecoveryHandler.getInstance();
217
+ handler.reset();
218
+ });
219
+
220
+ after(() => {
221
+ handler.reset();
222
+ });
223
+
224
+ it("succeeds on first attempt", async () => {
225
+ const result = await handler.withRecovery("wr1", async () => "hello");
226
+ assert.equal(result, "hello");
227
+ });
228
+
229
+ it("retries on retryable error then succeeds", async () => {
230
+ handler.reset();
231
+ let callCount = 0;
232
+
233
+ const result = await handler.withRecovery("wr2", async () => {
234
+ callCount++;
235
+ if (callCount === 1) throw new Error("parse error: invalid json");
236
+ return "ok-after-retry";
237
+ });
238
+
239
+ assert.equal(result, "ok-after-retry");
240
+ assert.equal(callCount, 2);
241
+ });
242
+
243
+ it("throws on abort action", async () => {
244
+ handler.reset();
245
+
246
+ await assert.rejects(
247
+ handler.withRecovery("wr3", async () => {
248
+ throw new Error("session not found");
249
+ }),
250
+ /session not found/,
251
+ );
252
+ });
253
+
254
+ it("throws on escalate action", async () => {
255
+ handler.reset();
256
+
257
+ await assert.rejects(
258
+ handler.withRecovery("wr4", async () => {
259
+ throw new Error("unknown tool: foo");
260
+ }),
261
+ /unknown tool/,
262
+ );
263
+ });
264
+
265
+ it("respects category maxAttempts and then throws", async () => {
266
+ handler.reset();
267
+
268
+ await assert.rejects(
269
+ handler.withRecovery("wr5", async () => {
270
+ throw new Error("parse error: bad json");
271
+ }),
272
+ /parse error/,
273
+ );
274
+ });
275
+
276
+ it("respects global maxAttempts option", async () => {
277
+ handler.reset();
278
+ let callCount = 0;
279
+
280
+ // Use an unrecognized error (will escalate on first try — so throw immediately).
281
+ // Instead let's use a timeout pattern which has maxAttempts=2, but set global maxAttempts=1
282
+ await assert.rejects(
283
+ handler.withRecovery(
284
+ "wr6",
285
+ async () => {
286
+ callCount++;
287
+ throw new Error("execution timed out");
288
+ },
289
+ { maxAttempts: 1 },
290
+ ),
291
+ );
292
+ // With maxAttempts=1, it should only be called once
293
+ assert.equal(callCount, 1);
294
+ });
295
+
296
+ it("tracks success and failure in stats", async () => {
297
+ handler.reset();
298
+
299
+ // One success
300
+ await handler.withRecovery("wr7", async () => "good");
301
+ // One abort (failure)
302
+ await assert.rejects(
303
+ handler.withRecovery("wr7", async () => {
304
+ throw new Error("session expired");
305
+ }),
306
+ );
307
+
308
+ const stats = handler.getStats();
309
+ assert.ok(stats.successRate > 0);
310
+ assert.ok(stats.successRate < 1);
311
+ });
312
+ });
@@ -0,0 +1,127 @@
1
+ // ---------------------------------------------------------------------------
2
+ // AnomalyTracker — singleton that tracks consecutive anomaly records per session
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import type { AnomalyRecord, AnomalyTrackerConfig, SanityResult } from "./interfaces.ts";
6
+
7
+ const DEFAULT_CONFIG: AnomalyTrackerConfig = {
8
+ maxConsecutiveAnomalies: 2,
9
+ escalationMessage: "recovery: compact context",
10
+ };
11
+
12
+ export class AnomalyTracker {
13
+ private static instance: AnomalyTracker;
14
+
15
+ private records = new Map<string, AnomalyRecord>();
16
+ private config: AnomalyTrackerConfig;
17
+
18
+ private constructor(config?: Partial<AnomalyTrackerConfig>) {
19
+ this.config = { ...DEFAULT_CONFIG, ...config };
20
+ }
21
+
22
+ /** Get the singleton instance. */
23
+ static getInstance(config?: Partial<AnomalyTrackerConfig>): AnomalyTracker {
24
+ if (!AnomalyTracker.instance) {
25
+ AnomalyTracker.instance = new AnomalyTracker(config);
26
+ }
27
+ return AnomalyTracker.instance;
28
+ }
29
+
30
+ /**
31
+ * Record a sanity result for a session.
32
+ * If unhealthy: increments consecutive count, updates reason/timestamp.
33
+ * If healthy: resets consecutive count to 0.
34
+ * Returns tracking info including whether escalation is needed.
35
+ */
36
+ record(
37
+ sessionId: string,
38
+ result: SanityResult,
39
+ ): {
40
+ shouldEscalate: boolean;
41
+ consecutiveAnomalies: number;
42
+ recoveryMessage?: string;
43
+ } {
44
+ const existing = this.records.get(sessionId);
45
+
46
+ if (!result.isHealthy) {
47
+ const count = (existing?.count ?? 0) + 1;
48
+ this.records.set(sessionId, {
49
+ sessionId,
50
+ count,
51
+ lastReason: result.reason ?? "Unknown anomaly",
52
+ lastTimestamp: Date.now(),
53
+ });
54
+
55
+ const shouldEscalate = count >= this.config.maxConsecutiveAnomalies;
56
+
57
+ return {
58
+ shouldEscalate,
59
+ consecutiveAnomalies: count,
60
+ recoveryMessage: shouldEscalate ? this.config.escalationMessage : undefined,
61
+ };
62
+ }
63
+
64
+ // Healthy output — reset counter
65
+ if (existing) {
66
+ this.records.set(sessionId, {
67
+ ...existing,
68
+ count: 0,
69
+ lastReason: "reset on healthy output",
70
+ lastTimestamp: Date.now(),
71
+ });
72
+ }
73
+
74
+ return {
75
+ shouldEscalate: false,
76
+ consecutiveAnomalies: 0,
77
+ };
78
+ }
79
+
80
+ /** Get the current anomaly record for a session. */
81
+ getRecord(sessionId: string): AnomalyRecord | undefined {
82
+ return this.records.get(sessionId);
83
+ }
84
+
85
+ /** Clear anomaly record for a specific session. */
86
+ clearSession(sessionId: string): void {
87
+ this.records.delete(sessionId);
88
+ }
89
+
90
+ /** Reset all tracking state (useful in tests). */
91
+ resetAll(): void {
92
+ this.records.clear();
93
+ }
94
+
95
+ /** Get the current config. */
96
+ getConfig(): AnomalyTrackerConfig {
97
+ return { ...this.config };
98
+ }
99
+
100
+ /** Update config at runtime. */
101
+ setConfig(config: Partial<AnomalyTrackerConfig>): void {
102
+ this.config = { ...this.config, ...config };
103
+ }
104
+
105
+ // ── Cross-invocation identical output detection ────────────────
106
+
107
+ private lastOutput: string | null = null;
108
+ private identicalOutputCount = 0;
109
+ readonly MAX_IDENTICAL_OUTPUTS = 3;
110
+
111
+ /**
112
+ * Track output for repeated identical content.
113
+ * Returns true if output should be flagged as degenerate.
114
+ */
115
+ trackOutput(text: string): boolean {
116
+ if (text === this.lastOutput) {
117
+ this.identicalOutputCount++;
118
+ if (this.identicalOutputCount >= this.MAX_IDENTICAL_OUTPUTS) {
119
+ return true; // Flagged — repeated identical output
120
+ }
121
+ } else {
122
+ this.identicalOutputCount = 0;
123
+ }
124
+ this.lastOutput = text;
125
+ return false;
126
+ }
127
+ }