@hmharness/kernel 0.5.4 → 0.6.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/loop.d.ts CHANGED
@@ -45,4 +45,13 @@ export declare function runLoop(opts: {
45
45
  previousDigest: string | null;
46
46
  evicted: string[];
47
47
  }) => Promise<string>;
48
+ /** Hard total turn cap — override for testing (default: unlimited; the
49
+ * loop stops on idle detection, not turn count — Codex-style
50
+ * fire-and-forget for long-running development tasks). */
51
+ maxTotalTurns?: number;
52
+ /** Idle detection: consecutive turns with zero successful tool calls
53
+ * before the loop concludes the agent is stuck (default: 15). */
54
+ maxIdleTurns?: number;
55
+ /** Hard total token spend cap (default: 50M — generous enough for days). */
56
+ maxTotalTokens?: number;
48
57
  }): Promise<LoopResult>;
package/dist/loop.js CHANGED
@@ -13,24 +13,41 @@ import { chat } from "./provider.js";
13
13
  export async function runLoop(opts) {
14
14
  const { provider, registry, ctx, events } = opts;
15
15
  const modelCall = opts.chatImpl ?? chat;
16
- // Turn limit scales with the model's context window: 25 for small models,
17
- // up to 80 for 1M-window models. The context compaction keeps the
18
- // transcript within budget throughout, so the real ceiling is how many
19
- // reasoning turns the model can sustain, not raw token count.
20
- const maxTurns = opts.maxTurns ?? adaptiveMaxTurns(provider);
16
+ // Codex-style: no turn cap. The loop runs until the model gives a final
17
+ // answer, goes idle (no successful tool calls for N turns), or hits a
18
+ // very generous token valve. Soft checkpoint nudges remain as guidance.
19
+ const softTurnLimit = adaptiveMaxTurns(provider);
20
+ const hardTurnLimit = opts.maxTotalTurns ?? Infinity; // no cap by default
21
+ const maxIdle = opts.maxIdleTurns ?? 15; // stuck detector
22
+ const hardTokenLimit = opts.maxTotalTokens ?? 50_000_000;
21
23
  const budget = opts.maxContextChars ?? adaptiveContextChars(provider);
22
24
  const working = [...opts.messages];
23
25
  let toolUses = 0;
24
26
  const usage = { promptTokens: 0, completionTokens: 0 };
25
27
  const tools = registry.toOpenAITools();
26
28
  let wrapupSignaled = false;
27
- for (let turn = 1; turn <= maxTurns; turn++) {
29
+ let turn = 0;
30
+ let idleTurns = 0; // consecutive turns with no successful tool calls
31
+ let reason = 'final';
32
+ // The loop runs until the model gives a final answer (no tool calls), goes
33
+ // idle (stuck), or hits a safety valve. Soft checkpoints at the adaptive
34
+ // turn limit nudge the model but don't stop it — days-long tasks run
35
+ // uninterrupted (Codex fire-and-forget philosophy).
36
+ while (true) {
37
+ turn++;
38
+ if (turn > hardTurnLimit) {
39
+ reason = 'turn-valve';
40
+ break;
41
+ }
42
+ if (usage.promptTokens + usage.completionTokens > hardTokenLimit) {
43
+ reason = 'token-valve';
44
+ break;
45
+ }
28
46
  const compacted = opts.summarizeContext
29
47
  ? await compactWithDigest(working, budget, opts.summarizeContext)
30
48
  : compactMessages(working, budget);
31
- // Budget wrap-up signal: when context usage crosses 80%, inject a
32
- // single system nudge to wind down instead of hard-cutting mid-thought
33
- // (Codex's token-budget-context pattern; DeepSeek's 80% pressure trigger).
49
+ // Context budget wrap-up signal: when usage crosses 80%, nudge the model
50
+ // to wind down (Codex token_budget_context + DeepSeek 80% pressure).
34
51
  if (!wrapupSignaled && transcriptChars(compacted) > budget * 0.8) {
35
52
  wrapupSignaled = true;
36
53
  working.push({
@@ -38,6 +55,14 @@ export async function runLoop(opts) {
38
55
  content: '[context budget] Context is running low. Wrap up the current task: summarize what was accomplished, suggest concrete next steps, and stop starting new sub-tasks.',
39
56
  });
40
57
  }
58
+ // Soft turn checkpoint: at the adaptive limit, nudge to conclude — but
59
+ // the model can keep working if it's mid-task (no cap, fire-and-forget).
60
+ if (turn === softTurnLimit || (turn > softTurnLimit && turn % softTurnLimit === 0)) {
61
+ working.push({
62
+ role: 'system',
63
+ content: `[turn checkpoint] You have been working for ${turn} turns. If the task is substantially complete, give your final answer. If not, continue — there is no turn limit; work until done.`,
64
+ });
65
+ }
41
66
  const chatRes = await modelCall(provider, compacted, tools, {
42
67
  onDelta: events?.onDelta,
43
68
  });
@@ -111,8 +136,31 @@ export async function runLoop(opts) {
111
136
  content: p.output.length > 60_000 ? p.output.slice(0, 60_000) + '\n...[truncated]' : p.output,
112
137
  });
113
138
  }
139
+ // Idle detection: count consecutive turns where NO tool succeeded. A
140
+ // productive agent always has at least one successful call; N consecutive
141
+ // all-fail/all-skip turns = the agent is stuck in a loop (this replaces
142
+ // the old hard turn cap — Codex-style "run until done, not until N").
143
+ const anySuccess = planned.some((p) => !p.isError && !p.skip);
144
+ if (anySuccess) {
145
+ idleTurns = 0;
146
+ }
147
+ else {
148
+ idleTurns++;
149
+ if (idleTurns >= maxIdle) {
150
+ reason = 'idle';
151
+ break;
152
+ }
153
+ }
154
+ // Loop continues: the model was actively calling tools, so we keep going
155
+ // indefinitely (no turn cap). Only idle detection or the token valve stops us.
114
156
  }
115
- const text = `Turn limit reached (${maxTurns} turns). The session is preserved — continue with "hmh resume" or just send another message to pick up where this left off.`;
116
- events?.onFinal?.(text, maxTurns);
117
- return { text, turns: maxTurns, toolUses, messages: working, usage };
157
+ // Safety valve or idle detection fired
158
+ const executedTurns = reason === 'idle' ? turn : turn - 1;
159
+ const text = reason === 'idle'
160
+ ? `Agent appears stuck: ${maxIdle} consecutive turns with no successful tool calls. The session is preserved — review the transcript, adjust the approach, and send a new message to continue.`
161
+ : reason === 'turn-valve'
162
+ ? `Turn limit reached (${executedTurns} turns). The session is preserved — send another message to continue.`
163
+ : `Token budget limit reached (~${usage.promptTokens + usage.completionTokens} tokens). The session is preserved — send another message to continue.`;
164
+ events?.onFinal?.(text, executedTurns);
165
+ return { text, turns: executedTurns, toolUses, messages: working, usage };
118
166
  }
package/dist/provider.js CHANGED
@@ -15,7 +15,12 @@ export async function chat(cfg, messages, tools, opts = {}) {
15
15
  : { 'Content-Type': 'application/json', [typeof scheme === 'string' ? scheme : 'X-Api-Key']: cfg.apiKey };
16
16
  let authScheme = cfg.authHeader ?? 'bearer';
17
17
  let lastError = '';
18
- for (let attempt = 0; attempt < 2; attempt++) {
18
+ // Resilient retry: exponential backoff with jitter, up to 6 attempts
19
+ // (Codex-style fire-and-forget: network hiccups, 429s, and provider
20
+ // restarts should NEVER kill a long-running task). Retry-After header
21
+ // from 429s is honoured when present.
22
+ const MAX_RETRIES = 6;
23
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
19
24
  const ctrl = new AbortController();
20
25
  const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? cfg.timeoutMs ?? 120_000);
21
26
  try {
@@ -25,16 +30,23 @@ export async function chat(cfg, messages, tools, opts = {}) {
25
30
  body: JSON.stringify(body),
26
31
  signal: ctrl.signal,
27
32
  });
28
- if (res.status === 429 || res.status >= 500) {
33
+ if (res.status === 429) {
34
+ // honour server-provided retry delay; fall back to exponential backoff
35
+ const retryAfter = Number(res.headers.get('retry-after')) || Number(res.headers.get('x-ratelimit-reset')) || 0;
36
+ const delay = retryAfter > 0 ? retryAfter * 1000 : Math.min(30_000, 2000 * Math.pow(2, attempt));
37
+ lastError = `HTTP 429 (rate limited): ${(await res.text()).slice(0, 200)}`;
38
+ await sleep(delay + Math.random() * 1000); // jitter
39
+ continue;
40
+ }
41
+ if (res.status >= 500) {
42
+ const delay = Math.min(30_000, 2000 * Math.pow(2, attempt));
29
43
  lastError = `HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`;
30
- await sleep(1500 * (attempt + 1));
44
+ await sleep(delay + Math.random() * 1000);
31
45
  continue;
32
46
  }
33
47
  if (res.status === 401 && !cfg.authHeader && authScheme === 'bearer' && cfg.apiKey) {
34
- // gateway rejected Bearer - try the other common scheme once
35
48
  authScheme = 'X-Api-Key';
36
49
  lastError = 'renegotiating auth: Bearer rejected, retrying with X-Api-Key';
37
- attempt--;
38
50
  continue;
39
51
  }
40
52
  if (!res.ok) {
@@ -59,15 +71,19 @@ export async function chat(cfg, messages, tools, opts = {}) {
59
71
  }
60
72
  catch (err) {
61
73
  lastError = String(err);
62
- if (!/abort|fetch failed|ECONN|timeout/i.test(lastError))
63
- throw err;
64
- await sleep(1500 * (attempt + 1));
74
+ // transient (network, timeout, connection reset): retry with backoff
75
+ if (/abort|fetch failed|ECONN|EAI_AGAIN|ENOTFOUND|timeout|socket hang up/i.test(lastError)) {
76
+ const delay = Math.min(60_000, 3000 * Math.pow(2, attempt));
77
+ await sleep(delay + Math.random() * 2000);
78
+ continue;
79
+ }
80
+ throw err; // permanent error (bad JSON, logic error): don't retry
65
81
  }
66
82
  finally {
67
83
  clearTimeout(timer);
68
84
  }
69
85
  }
70
- throw new Error(`provider: failed after retry (${cfg.baseUrl}): ${lastError}`);
86
+ throw new Error(`provider: failed after ${MAX_RETRIES} retries (${cfg.baseUrl}): ${lastError}`);
71
87
  }
72
88
  /** Assemble a ChatResponse from an SSE stream, emitting deltas as they land. */
73
89
  async function consumeStream(res, onDelta) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/kernel",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",