@hmharness/kernel 0.3.0 → 0.4.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/context.d.ts +28 -1
- package/dist/context.js +62 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loop.d.ts +9 -0
- package/dist/loop.js +7 -2
- package/dist/types.d.ts +4 -0
- package/dist/window.d.ts +40 -0
- package/dist/window.js +47 -0
- package/package.json +1 -1
package/dist/context.d.ts
CHANGED
|
@@ -3,9 +3,36 @@
|
|
|
3
3
|
* Char-budget context compaction. Long agent runs are dominated by stale
|
|
4
4
|
* tool output; when the transcript exceeds its budget we replace the oldest
|
|
5
5
|
* tool results (never the system prompt, never the task itself, never the
|
|
6
|
-
* recent tail) with a tombstone. Deterministic, no model call
|
|
6
|
+
* recent tail) with a tombstone. Deterministic baseline, no model call.
|
|
7
|
+
*
|
|
8
|
+
* Rolling digest (model-aware context engineering, part 3): with a
|
|
9
|
+
* `summarize` hook, evicted content is first distilled into a persistent
|
|
10
|
+
* "[rolling digest]" system note that survives future compactions and
|
|
11
|
+
* merges with prior digests - the conversation's past shrinks semantically
|
|
12
|
+
* instead of being dropped. Without the hook, behaviour is byte-identical
|
|
13
|
+
* to the legacy prune.
|
|
7
14
|
*/
|
|
8
15
|
import type { ChatMessage } from './types.ts';
|
|
9
16
|
export declare const DEFAULT_CONTEXT_CHARS = 160000;
|
|
10
17
|
export declare function transcriptChars(messages: ChatMessage[]): number;
|
|
18
|
+
export declare const DIGEST_MARK = "[rolling digest of earlier context]";
|
|
11
19
|
export declare function compactMessages(messages: ChatMessage[], budget?: number): ChatMessage[];
|
|
20
|
+
export interface CompactResult {
|
|
21
|
+
messages: ChatMessage[];
|
|
22
|
+
/** Chars of tool output actually evicted this pass (0 = no compaction). */
|
|
23
|
+
evictedChars: number;
|
|
24
|
+
}
|
|
25
|
+
/** Deterministic prune + tombstone; reports what was evicted so callers can
|
|
26
|
+
* feed it to a summarizer. */
|
|
27
|
+
export declare function compactWithEvictions(messages: ChatMessage[], budget?: number): CompactResult;
|
|
28
|
+
/**
|
|
29
|
+
* Compact with a rolling digest. The digest is ONE system note placed right
|
|
30
|
+
* after the first user message (inside the protected head), so it survives
|
|
31
|
+
* every future compaction. Each pass merges: summarize(previous digest body
|
|
32
|
+
* + newly evicted tool outputs). Summarizer failures degrade silently to
|
|
33
|
+
* the deterministic prune (the digest just stops growing).
|
|
34
|
+
*/
|
|
35
|
+
export declare function compactWithDigest(messages: ChatMessage[], budget: number, summarize: (input: {
|
|
36
|
+
previousDigest: string | null;
|
|
37
|
+
evicted: string[];
|
|
38
|
+
}) => Promise<string>): Promise<ChatMessage[]>;
|
package/dist/context.js
CHANGED
|
@@ -18,6 +18,10 @@ function protectedRange(messages) {
|
|
|
18
18
|
keep.add(i);
|
|
19
19
|
return keep;
|
|
20
20
|
}
|
|
21
|
+
export const DIGEST_MARK = '[rolling digest of earlier context]';
|
|
22
|
+
function findDigest(messages) {
|
|
23
|
+
return messages.findIndex((m) => m.role === 'system' && typeof m.content === 'string' && m.content.startsWith(DIGEST_MARK));
|
|
24
|
+
}
|
|
21
25
|
export function compactMessages(messages, budget = DEFAULT_CONTEXT_CHARS) {
|
|
22
26
|
if (transcriptChars(messages) <= budget)
|
|
23
27
|
return messages;
|
|
@@ -30,3 +34,61 @@ export function compactMessages(messages, budget = DEFAULT_CONTEXT_CHARS) {
|
|
|
30
34
|
}
|
|
31
35
|
return out;
|
|
32
36
|
}
|
|
37
|
+
/** Deterministic prune + tombstone; reports what was evicted so callers can
|
|
38
|
+
* feed it to a summarizer. */
|
|
39
|
+
export function compactWithEvictions(messages, budget = DEFAULT_CONTEXT_CHARS) {
|
|
40
|
+
if (transcriptChars(messages) <= budget)
|
|
41
|
+
return { messages, evictedChars: 0 };
|
|
42
|
+
const keep = protectedRange(messages);
|
|
43
|
+
const out = messages.map((m) => ({ ...m }));
|
|
44
|
+
let evicted = 0;
|
|
45
|
+
for (let i = 0; i < out.length && transcriptChars(out) > budget; i++) {
|
|
46
|
+
if (keep.has(i) || out[i].role !== 'tool')
|
|
47
|
+
continue;
|
|
48
|
+
evicted += out[i].content?.length ?? 0;
|
|
49
|
+
out[i] = { ...out[i], content: '[context pruned: earlier tool output removed to fit budget]' };
|
|
50
|
+
}
|
|
51
|
+
return { messages: out, evictedChars: evicted };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compact with a rolling digest. The digest is ONE system note placed right
|
|
55
|
+
* after the first user message (inside the protected head), so it survives
|
|
56
|
+
* every future compaction. Each pass merges: summarize(previous digest body
|
|
57
|
+
* + newly evicted tool outputs). Summarizer failures degrade silently to
|
|
58
|
+
* the deterministic prune (the digest just stops growing).
|
|
59
|
+
*/
|
|
60
|
+
export async function compactWithDigest(messages, budget, summarize) {
|
|
61
|
+
const { messages: pruned, evictedChars } = compactWithEvictions(messages, budget);
|
|
62
|
+
if (evictedChars === 0)
|
|
63
|
+
return pruned;
|
|
64
|
+
const digestIdx = findDigest(pruned);
|
|
65
|
+
const previousDigest = digestIdx >= 0 ? (pruned[digestIdx].content ?? '').slice(DIGEST_MARK.length).trim() : null;
|
|
66
|
+
const evicted = [];
|
|
67
|
+
// what was evicted = tombstoned positions vs the input (compare by index)
|
|
68
|
+
for (let i = 0; i < pruned.length; i++) {
|
|
69
|
+
if (pruned[i].role === 'tool' && pruned[i].content === '[context pruned: earlier tool output removed to fit budget]'
|
|
70
|
+
&& messages[i]?.role === 'tool' && messages[i].content !== pruned[i].content) {
|
|
71
|
+
evicted.push(String(messages[i].content).slice(0, 4000));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (evicted.length === 0 && previousDigest)
|
|
75
|
+
return pruned; // nothing new to distill
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
body = (await summarize({ previousDigest, evicted })).trim().slice(0, 12_000);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return pruned; // summarizer down -> keep deterministic result
|
|
82
|
+
}
|
|
83
|
+
if (!body)
|
|
84
|
+
return pruned;
|
|
85
|
+
const note = { role: 'system', content: `${DIGEST_MARK}\n${body}` };
|
|
86
|
+
if (digestIdx >= 0) {
|
|
87
|
+
const out = pruned.map((m, i) => (i === digestIdx ? note : m));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
// insert after the first user message (protected head)
|
|
91
|
+
const firstUser = pruned.findIndex((m) => m.role === 'user');
|
|
92
|
+
const at = firstUser >= 0 ? firstUser + 1 : pruned.length;
|
|
93
|
+
return [...pruned.slice(0, at), note, ...pruned.slice(at)];
|
|
94
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/loop.d.ts
CHANGED
|
@@ -31,9 +31,18 @@ export declare function runLoop(opts: {
|
|
|
31
31
|
messages: ChatMessage[];
|
|
32
32
|
ctx: ToolContext;
|
|
33
33
|
maxTurns?: number;
|
|
34
|
+
/** Explicit transcript budget override (chars). Default: scales with the
|
|
35
|
+
* model's context window (window.ts registry / provider.contextWindow). */
|
|
34
36
|
maxContextChars?: number;
|
|
35
37
|
approval?: LoopApproval;
|
|
36
38
|
events?: LoopEvents;
|
|
37
39
|
/** Injectable model call (tests pass a fake; production uses provider.chat). */
|
|
38
40
|
chatImpl?: typeof chat;
|
|
41
|
+
/** Rolling digest hook (model-aware context engineering): when compaction
|
|
42
|
+
* evicts content, it is distilled into a persistent digest note instead
|
|
43
|
+
* of being dropped. Absent -> deterministic prune only. */
|
|
44
|
+
summarizeContext?: (input: {
|
|
45
|
+
previousDigest: string | null;
|
|
46
|
+
evicted: string[];
|
|
47
|
+
}) => Promise<string>;
|
|
39
48
|
}): Promise<LoopResult>;
|
package/dist/loop.js
CHANGED
|
@@ -7,18 +7,23 @@
|
|
|
7
7
|
* no gate configured means deny (safe default). Between turns the
|
|
8
8
|
* transcript is compacted against the context budget.
|
|
9
9
|
*/
|
|
10
|
-
import { compactMessages } from "./context.js";
|
|
10
|
+
import { compactMessages, compactWithDigest } from "./context.js";
|
|
11
|
+
import { adaptiveContextChars } from "./window.js";
|
|
11
12
|
import { chat } from "./provider.js";
|
|
12
13
|
export async function runLoop(opts) {
|
|
13
14
|
const { provider, registry, ctx, events } = opts;
|
|
14
15
|
const modelCall = opts.chatImpl ?? chat;
|
|
15
16
|
const maxTurns = opts.maxTurns ?? 25;
|
|
17
|
+
const budget = opts.maxContextChars ?? adaptiveContextChars(provider);
|
|
16
18
|
const working = [...opts.messages];
|
|
17
19
|
let toolUses = 0;
|
|
18
20
|
const usage = { promptTokens: 0, completionTokens: 0 };
|
|
19
21
|
const tools = registry.toOpenAITools();
|
|
20
22
|
for (let turn = 1; turn <= maxTurns; turn++) {
|
|
21
|
-
const
|
|
23
|
+
const compacted = opts.summarizeContext
|
|
24
|
+
? await compactWithDigest(working, budget, opts.summarizeContext)
|
|
25
|
+
: compactMessages(working, budget);
|
|
26
|
+
const chatRes = await modelCall(provider, compacted, tools, {
|
|
22
27
|
onDelta: events?.onDelta,
|
|
23
28
|
});
|
|
24
29
|
usage.promptTokens += chatRes.usage?.prompt_tokens ?? 0;
|
package/dist/types.d.ts
CHANGED
|
@@ -64,6 +64,10 @@ export interface ProviderConfig {
|
|
|
64
64
|
* tokenrouter took 84s on an evolve-sized prompt vs the 120s default)
|
|
65
65
|
* set e.g. 240000 on the evolve/bench routes. */
|
|
66
66
|
timeoutMs?: number;
|
|
67
|
+
/** Explicit context window (tokens) for this model. Overrides the
|
|
68
|
+
* built-in registry; the transcript budget then scales to the window
|
|
69
|
+
* (see window.ts) instead of the fixed legacy default. */
|
|
70
|
+
contextWindow?: number;
|
|
67
71
|
}
|
|
68
72
|
/** User-level configuration (HMH_HOME/config.json). */
|
|
69
73
|
export interface HmhConfig {
|
package/dist/window.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - window (model-aware context engineering, part 1)
|
|
3
|
+
* The context budget used to be one fixed char number for every model - a
|
|
4
|
+
* 128K-window flash model and a 1M-window model got the same 160K chars
|
|
5
|
+
* (small windows overflow, huge windows waste). This registry maps model
|
|
6
|
+
* names to their context windows; budgets then scale with the window.
|
|
7
|
+
*
|
|
8
|
+
* Layered resolution (first hit wins):
|
|
9
|
+
* 1. ProviderConfig.contextWindow (explicit, tokens) - the user is right
|
|
10
|
+
* 2. registry pattern match on the model name - conservative values
|
|
11
|
+
* 3. unknown -> null (callers fall back to the legacy fixed default)
|
|
12
|
+
*
|
|
13
|
+
* Registry values are deliberately CONSERVATIVE (never the vendor max):
|
|
14
|
+
* a wrong-low budget costs a bit of context; a wrong-high one costs the run.
|
|
15
|
+
*/
|
|
16
|
+
export interface ContextWindowInfo {
|
|
17
|
+
windowTokens: number | null;
|
|
18
|
+
source: 'config' | 'registry' | 'unknown';
|
|
19
|
+
}
|
|
20
|
+
export declare function contextWindowFor(p: {
|
|
21
|
+
model: string;
|
|
22
|
+
contextWindow?: number;
|
|
23
|
+
}): ContextWindowInfo;
|
|
24
|
+
/** Transcript char budget for a window: ~2.5 chars/token mixed-language
|
|
25
|
+
* (ASCII ~4, CJK ~1.2), and only HALF the window may be history - the rest
|
|
26
|
+
* belongs to system prompt, injected memory/skills, tool schemas and the
|
|
27
|
+
* reply. 128K tokens -> 160K chars, which is exactly the legacy default -
|
|
28
|
+
* known-window models scale from there, unknown models keep today's
|
|
29
|
+
* behaviour unchanged. */
|
|
30
|
+
export declare const CHARS_PER_TOKEN = 2.5;
|
|
31
|
+
export declare const HISTORY_WINDOW_FRACTION = 0.5;
|
|
32
|
+
export declare const MIN_CONTEXT_CHARS = 40000;
|
|
33
|
+
export declare const MAX_CONTEXT_CHARS = 1000000;
|
|
34
|
+
export declare const LEGACY_DEFAULT_CONTEXT_CHARS = 160000;
|
|
35
|
+
export declare function contextBudgetChars(windowTokens: number | null): number;
|
|
36
|
+
/** One-call helper: provider -> adaptive transcript char budget. */
|
|
37
|
+
export declare function adaptiveContextChars(p: {
|
|
38
|
+
model: string;
|
|
39
|
+
contextWindow?: number;
|
|
40
|
+
}): number;
|
package/dist/window.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Ordered [pattern, conservative window tokens]. First match wins. */
|
|
2
|
+
const REGISTRY = [
|
|
3
|
+
[/claude/i, 200_000],
|
|
4
|
+
[/gpt-4\.1/i, 1_000_000],
|
|
5
|
+
[/gpt-5/i, 400_000],
|
|
6
|
+
[/gpt-4o/i, 128_000],
|
|
7
|
+
[/\bo[34](-mini)?\b/i, 200_000],
|
|
8
|
+
[/gemini/i, 1_000_000],
|
|
9
|
+
[/glm-\d/i, 131_072],
|
|
10
|
+
[/deepseek/i, 128_000],
|
|
11
|
+
[/kimi/i, 131_072],
|
|
12
|
+
[/qwen/i, 131_072],
|
|
13
|
+
[/llama/i, 128_000],
|
|
14
|
+
[/doubao/i, 128_000],
|
|
15
|
+
[/hunyuan|ernie|minimax|step-/i, 128_000],
|
|
16
|
+
];
|
|
17
|
+
export function contextWindowFor(p) {
|
|
18
|
+
if (typeof p.contextWindow === 'number' && p.contextWindow > 0) {
|
|
19
|
+
return { windowTokens: p.contextWindow, source: 'config' };
|
|
20
|
+
}
|
|
21
|
+
for (const [re, tokens] of REGISTRY) {
|
|
22
|
+
if (re.test(p.model))
|
|
23
|
+
return { windowTokens: tokens, source: 'registry' };
|
|
24
|
+
}
|
|
25
|
+
return { windowTokens: null, source: 'unknown' };
|
|
26
|
+
}
|
|
27
|
+
/** Transcript char budget for a window: ~2.5 chars/token mixed-language
|
|
28
|
+
* (ASCII ~4, CJK ~1.2), and only HALF the window may be history - the rest
|
|
29
|
+
* belongs to system prompt, injected memory/skills, tool schemas and the
|
|
30
|
+
* reply. 128K tokens -> 160K chars, which is exactly the legacy default -
|
|
31
|
+
* known-window models scale from there, unknown models keep today's
|
|
32
|
+
* behaviour unchanged. */
|
|
33
|
+
export const CHARS_PER_TOKEN = 2.5;
|
|
34
|
+
export const HISTORY_WINDOW_FRACTION = 0.5;
|
|
35
|
+
export const MIN_CONTEXT_CHARS = 40_000;
|
|
36
|
+
export const MAX_CONTEXT_CHARS = 1_000_000;
|
|
37
|
+
export const LEGACY_DEFAULT_CONTEXT_CHARS = 160_000;
|
|
38
|
+
export function contextBudgetChars(windowTokens) {
|
|
39
|
+
if (windowTokens === null)
|
|
40
|
+
return LEGACY_DEFAULT_CONTEXT_CHARS;
|
|
41
|
+
const raw = windowTokens * CHARS_PER_TOKEN * HISTORY_WINDOW_FRACTION;
|
|
42
|
+
return Math.round(Math.min(MAX_CONTEXT_CHARS, Math.max(MIN_CONTEXT_CHARS, raw)));
|
|
43
|
+
}
|
|
44
|
+
/** One-call helper: provider -> adaptive transcript char budget. */
|
|
45
|
+
export function adaptiveContextChars(p) {
|
|
46
|
+
return contextBudgetChars(contextWindowFor(p).windowTokens);
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|