@hmharness/kernel 0.5.5 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loop.d.ts CHANGED
@@ -45,8 +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 — the safety valve (default: 5x the adaptive soft limit, max 400). */
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). */
49
51
  maxTotalTurns?: number;
50
- /** Hard total token spend cap prompt + completion combined (default: 10M). */
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). */
51
56
  maxTotalTokens?: number;
52
57
  }): Promise<LoopResult>;
package/dist/loop.js CHANGED
@@ -13,12 +13,13 @@ 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
- // Soft limit: where the wrap-up nudge fires (adaptive to context window).
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.
17
19
  const softTurnLimit = adaptiveMaxTurns(provider);
18
- // Hard limit: the actual safety valve. If the model is still calling tools
19
- // at the soft limit, we auto-continue the loop only hard-stops here.
20
- const hardTurnLimit = opts.maxTotalTurns ?? Math.min(softTurnLimit * 5, 400);
21
- const hardTokenLimit = opts.maxTotalTokens ?? 10_000_000;
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;
22
23
  const budget = opts.maxContextChars ?? adaptiveContextChars(provider);
23
24
  const working = [...opts.messages];
24
25
  let toolUses = 0;
@@ -26,12 +27,12 @@ export async function runLoop(opts) {
26
27
  const tools = registry.toOpenAITools();
27
28
  let wrapupSignaled = false;
28
29
  let turn = 0;
30
+ let idleTurns = 0; // consecutive turns with no successful tool calls
29
31
  let reason = 'final';
30
- // The loop runs until the model gives a final answer (no tool calls) OR a
31
- // safety valve fires. The old "maxTurns stop" is replaced by a soft
32
- // checkpoint: if the model is still actively working (calling tools) at
33
- // the soft limit, the loop auto-continues — long-running tasks feel
34
- // unlimited while the hard valves protect against runaway loops and cost.
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).
35
36
  while (true) {
36
37
  turn++;
37
38
  if (turn > hardTurnLimit) {
@@ -55,11 +56,11 @@ export async function runLoop(opts) {
55
56
  });
56
57
  }
57
58
  // Soft turn checkpoint: at the adaptive limit, nudge to conclude — but
58
- // the model can keep working if it's mid-task (auto-continue).
59
- if (turn === softTurnLimit) {
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)) {
60
61
  working.push({
61
62
  role: 'system',
62
- content: `[turn checkpoint] You have been working for ${turn} turns. If the task is substantially complete, give your final answer now. If not, continue — the turn limit has been lifted; work until done.`,
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.`,
63
64
  });
64
65
  }
65
66
  const chatRes = await modelCall(provider, compacted, tools, {
@@ -135,14 +136,31 @@ export async function runLoop(opts) {
135
136
  content: p.output.length > 60_000 ? p.output.slice(0, 60_000) + '\n...[truncated]' : p.output,
136
137
  });
137
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
+ }
138
154
  // Loop continues: the model was actively calling tools, so we keep going
139
- // (auto-continuation past the soft turn limit). Only the safety valves
140
- // (hardTurnLimit / hardTokenLimit) can stop us now.
155
+ // indefinitely (no turn cap). Only idle detection or the token valve stops us.
141
156
  }
142
- // Safety valve fired
143
- const text = reason === 'turn-valve'
144
- ? `Safety turn limit reached (${turn - 1} turns, hard cap ${hardTurnLimit}). The session is preserved — send another message to continue with fresh limits.`
145
- : `Token budget limit reached (~${usage.promptTokens + usage.completionTokens} tokens, cap ${hardTokenLimit}). The session is preserved — send another message to continue.`;
146
- events?.onFinal?.(text, turn - 1);
147
- return { text, turns: turn - 1, 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 };
148
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.5",
3
+ "version": "0.6.1",
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",