@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 +7 -2
- package/dist/loop.js +39 -21
- package/dist/provider.js +25 -9
- package/package.json +1 -1
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 —
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
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)
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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 (
|
|
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
|
|
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
|
-
// (
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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.
|
|
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",
|