@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 +9 -0
- package/dist/loop.js +60 -12
- package/dist/provider.js +25 -9
- package/package.json +1 -1
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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.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",
|