@gaunt-sloth/core 2.0.0-beta.4 → 2.0.0-beta.6

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.
Files changed (94) hide show
  1. package/dist/config/schema.d.ts +4 -0
  2. package/dist/config/schema.js +64 -2
  3. package/dist/config/schema.js.map +1 -1
  4. package/dist/config/tokenBudget.d.ts +88 -0
  5. package/dist/config/tokenBudget.js +155 -0
  6. package/dist/config/tokenBudget.js.map +1 -0
  7. package/dist/config/types.d.ts +33 -4
  8. package/dist/config/types.js.map +1 -1
  9. package/dist/config.d.ts +1 -0
  10. package/dist/config.js +5 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/GthAbstractAgent.d.ts +24 -0
  13. package/dist/core/GthAbstractAgent.js +37 -1
  14. package/dist/core/GthAbstractAgent.js.map +1 -1
  15. package/dist/core/GthAgentRunner.d.ts +216 -1
  16. package/dist/core/GthAgentRunner.js +424 -3
  17. package/dist/core/GthAgentRunner.js.map +1 -1
  18. package/dist/core/GthLangChainAgent.d.ts +196 -0
  19. package/dist/core/GthLangChainAgent.js +392 -2
  20. package/dist/core/GthLangChainAgent.js.map +1 -1
  21. package/dist/core/approvals/approvalRequest.d.ts +142 -0
  22. package/dist/core/approvals/approvalRequest.js +198 -3
  23. package/dist/core/approvals/approvalRequest.js.map +1 -1
  24. package/dist/core/approvals/conversationGrants.d.ts +60 -0
  25. package/dist/core/approvals/conversationGrants.js +77 -0
  26. package/dist/core/approvals/conversationGrants.js.map +1 -0
  27. package/dist/core/approvals/grants.d.ts +16 -0
  28. package/dist/core/approvals/grants.js +20 -5
  29. package/dist/core/approvals/grants.js.map +1 -1
  30. package/dist/core/compaction.d.ts +181 -0
  31. package/dist/core/compaction.js +293 -0
  32. package/dist/core/compaction.js.map +1 -0
  33. package/dist/core/compactionThreshold.d.ts +158 -0
  34. package/dist/core/compactionThreshold.js +183 -0
  35. package/dist/core/compactionThreshold.js.map +1 -0
  36. package/dist/core/contextWindow.d.ts +146 -0
  37. package/dist/core/contextWindow.js +256 -0
  38. package/dist/core/contextWindow.js.map +1 -0
  39. package/dist/core/exitOutputChannel.d.ts +51 -0
  40. package/dist/core/exitOutputChannel.js +65 -0
  41. package/dist/core/exitOutputChannel.js.map +1 -0
  42. package/dist/core/refusal.d.ts +17 -2
  43. package/dist/core/refusal.js +80 -14
  44. package/dist/core/refusal.js.map +1 -1
  45. package/dist/core/runStats.d.ts +1 -1
  46. package/dist/core/terminationNotice.d.ts +8 -0
  47. package/dist/core/terminationNotice.js +10 -4
  48. package/dist/core/terminationNotice.js.map +1 -1
  49. package/dist/core/terminationReason.d.ts +28 -0
  50. package/dist/core/terminationReason.js +27 -0
  51. package/dist/core/terminationReason.js.map +1 -1
  52. package/dist/core/types.d.ts +35 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/history/checkpointRetention.d.ts +279 -0
  55. package/dist/history/checkpointRetention.js +567 -0
  56. package/dist/history/checkpointRetention.js.map +1 -0
  57. package/dist/history/checkpointSaver.d.ts +93 -0
  58. package/dist/history/checkpointSaver.js +464 -0
  59. package/dist/history/checkpointSaver.js.map +1 -0
  60. package/dist/history/historyEnabled.d.ts +27 -0
  61. package/dist/history/historyEnabled.js +23 -0
  62. package/dist/history/historyEnabled.js.map +1 -0
  63. package/dist/history/historyFormat.d.ts +27 -0
  64. package/dist/history/historyFormat.js +125 -2
  65. package/dist/history/historyFormat.js.map +1 -1
  66. package/dist/history/historyStore.d.ts +61 -0
  67. package/dist/history/historyStore.js +180 -7
  68. package/dist/history/historyStore.js.map +1 -1
  69. package/dist/history/recordSession.d.ts +84 -22
  70. package/dist/history/recordSession.js +187 -12
  71. package/dist/history/recordSession.js.map +1 -1
  72. package/dist/history/sessionCheckpointer.d.ts +48 -0
  73. package/dist/history/sessionCheckpointer.js +200 -0
  74. package/dist/history/sessionCheckpointer.js.map +1 -0
  75. package/dist/index.d.ts +6 -0
  76. package/dist/index.js +8 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/providers/modelCatalog.d.ts +14 -0
  79. package/dist/providers/modelCatalog.js +4 -0
  80. package/dist/providers/modelCatalog.js.map +1 -1
  81. package/dist/providers/modelDiscovery.d.ts +3 -1
  82. package/dist/providers/modelDiscovery.js +22 -8
  83. package/dist/providers/modelDiscovery.js.map +1 -1
  84. package/dist/providers/ollama.js +3 -19
  85. package/dist/providers/ollama.js.map +1 -1
  86. package/dist/runtime/conversation.js +7 -1
  87. package/dist/runtime/conversation.js.map +1 -1
  88. package/dist/runtime/singleShot.js +6 -1
  89. package/dist/runtime/singleShot.js.map +1 -1
  90. package/dist/utils/consoleUtils.d.ts +77 -0
  91. package/dist/utils/consoleUtils.js +81 -0
  92. package/dist/utils/consoleUtils.js.map +1 -1
  93. package/package.json +2 -2
  94. package/schema/gsloth-config.schema.json +33 -14
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * EXT-161 — **the preventive compaction threshold: one number, one place it is decided.**
4
+ *
5
+ * `contextWindow.ts` answers "how big is the window". This module answers the question that
6
+ * actually gates a compaction: **at what prompt size do we fold the conversation, and who said
7
+ * so.** Three things can say so, in this order:
8
+ *
9
+ * 1. **the running session** — `/autocompact 300K`, which wins for the rest of the session;
10
+ * 2. **the user's config** — the `autocompact` key;
11
+ * 3. **the default** — derived from the resolved window by the guard, which is the only place that
12
+ * knows how many tokens are being held back for the answer.
13
+ *
14
+ * **Compaction is ON BY DEFAULT.** That is a ruling, and it is a deliberate exception to the rule
15
+ * that a default-on middleware must not touch `state.messages` — compaction changes what the model
16
+ * sees by construction, which is the whole feature. The exception is paid for the way the rule
17
+ * demands: the shape of the history a compaction leaves behind is pinned per provider by
18
+ * `compaction.ts`'s invariants (a)–(d), a compaction announces itself in the transcript, the
19
+ * resolved number and its provenance are readable with `/status`, and the off switch is one key —
20
+ * `autocompact: false`.
21
+ *
22
+ * **Why the provenance is carried rather than recomputed.** A threshold that is wrong is diagnosed
23
+ * by knowing where the number came from; a `/status` that reports a models.dev-derived number after
24
+ * a human typed `/autocompact 50000` would send the next diagnosis to the wrong place entirely. So
25
+ * a session override re-labels the provenance, and {@link AutocompactController} is the single
26
+ * object both the guard and `/status` read, over one memoised window resolution.
27
+ */
28
+ import { type TokenBudget } from '#src/config/tokenBudget.js';
29
+ import type { ContextWindowOrigin, ResolvedContextWindow } from '#src/core/contextWindow.js';
30
+ /**
31
+ * The share of a model's context window that `gth init` seeds as an explicit threshold.
32
+ *
33
+ * Init writes an **absolute number** derived from this rather than the percentage itself, because
34
+ * the point of seeding is that the user opens their config and sees the number that will actually
35
+ * be enforced. A percentage would leave them one lookup away from it.
36
+ *
37
+ * 0.8 leaves a fifth of the window for the answer and the tool round that follows it — comfortably
38
+ * more than the flat answer reserve on any real cloud window, which is what makes the seeded number
39
+ * the binding one rather than a decoration.
40
+ */
41
+ export declare const DEFAULT_AUTOCOMPACT_SEED_FRACTION = 0.8;
42
+ /**
43
+ * The `autocompact` config value, in every form the key accepts.
44
+ *
45
+ * `false` disables it; `true` (and an absent key) is on with the derived default; a bare count or
46
+ * suffixed string is on with that threshold; the object form spells both out. The shorthand union
47
+ * mirrors `toolLoopGuard`, which is the shape a reader of this config already knows.
48
+ */
49
+ export type AutocompactConfig = boolean | number | string | {
50
+ enabled?: boolean;
51
+ threshold?: number | string;
52
+ };
53
+ /** The `autocompact` key as the read site sees it, after defaulting and parsing. */
54
+ export interface ResolvedAutocompactConfig {
55
+ /** Whether preventive compaction may fire at all. On by default — ruled. */
56
+ enabled: boolean;
57
+ /** The configured budget, or `null` when the user named none and the default applies. */
58
+ budget: TokenBudget | null;
59
+ }
60
+ /**
61
+ * Read the `autocompact` config key.
62
+ *
63
+ * Defaulting happens **here, at the read site**, not in `DEFAULT_CONFIG`, so an absent key stays
64
+ * absent in the effective-config snapshot and the snapshot does not churn — the same placement
65
+ * `injectModelContext` and `toolLoopGuard` use.
66
+ *
67
+ * A malformed threshold raises the `TokenBudgetError` from the shared parser, which config
68
+ * validation turns into a field-scoped issue naming the offending text. It must never resolve to a
69
+ * number: `NaN`, `0` and 4097 are all thresholds the user cannot see and did not choose.
70
+ */
71
+ export declare function resolveAutocompactConfig(raw: unknown): ResolvedAutocompactConfig;
72
+ /** Where the threshold number in force came from — what `/status` names. */
73
+ export type AutocompactThresholdOrigin =
74
+ /** A `/autocompact` typed in this session; outranks the config for the rest of it. */
75
+ 'session'
76
+ /** The `autocompact` key in the user's config. */
77
+ | 'config'
78
+ /** Derived from the resolved window, holding back room for the answer. */
79
+ | 'default'
80
+ /** Nothing will fire: switched off, or no window and no absolute threshold to fall back on. */
81
+ | 'none';
82
+ /** The whole picture, as `/status` prints it and the guard enforces it. */
83
+ export interface AutocompactStatus {
84
+ /** Whether preventive compaction may fire at all (the config off switch). */
85
+ enabled: boolean;
86
+ /**
87
+ * The prompt size, in tokens, at which the conversation is folded — or `null` when nothing will
88
+ * fire preventively, which is what an unknown window with no absolute threshold must produce.
89
+ */
90
+ thresholdTokens: number | null;
91
+ /** Where that number came from. */
92
+ thresholdOrigin: AutocompactThresholdOrigin;
93
+ /** The resolved context window, or `null` when no source knew it. */
94
+ window: number | null;
95
+ /** Which source the window came from. */
96
+ windowOrigin: ContextWindowOrigin;
97
+ /** The budget exactly as written, when one was written — so `/status` can echo `80%` as `80%`. */
98
+ budget: TokenBudget | null;
99
+ }
100
+ /** What {@link AutocompactController} needs to build a status. */
101
+ export interface AutocompactControllerOptions {
102
+ /** The `autocompact` key, already read through {@link resolveAutocompactConfig}. */
103
+ config: ResolvedAutocompactConfig;
104
+ /** The one memoised window resolution this session uses — shared with the guard. */
105
+ window: Pick<ResolvedContextWindow, 'read'>;
106
+ /**
107
+ * The threshold to use when the user named none, given a known window: the guard's
108
+ * `window − reserve`. Supplied as a callback because only the guard knows the reserve, and
109
+ * duplicating that arithmetic here is how the two would come to disagree about what "full" means.
110
+ */
111
+ defaultThreshold: (_window: number) => number;
112
+ }
113
+ /**
114
+ * **The single object the guard and `/status` both read.**
115
+ *
116
+ * Holds the session override — the write half of `/autocompact` — and resolves it against the
117
+ * config and the window on demand. It is mutable by design and the guard reads it through a
118
+ * closure rather than capturing a value, because `createContextGuardMiddleware` runs its factory
119
+ * once per session and its hook holds no state: a captured threshold could never be changed by a
120
+ * command typed later, which is precisely what `/autocompact` has to do.
121
+ */
122
+ export declare class AutocompactController {
123
+ private readonly options;
124
+ private sessionBudget;
125
+ constructor(options: AutocompactControllerOptions);
126
+ /**
127
+ * Set the threshold for the rest of this session, overriding the config.
128
+ *
129
+ * Deliberately takes an already-parsed {@link TokenBudget} rather than raw text: parsing is the
130
+ * shared parser's job, and a second entry point that took a string would be a second place the
131
+ * grammar could drift.
132
+ *
133
+ * **Refused while the config has compaction off.** `autocompact: false` means nothing fires,
134
+ * whatever number is named, so recording the budget would only make the next {@link status}
135
+ * describe a threshold that can never trigger. Nothing is recorded and the status is unchanged;
136
+ * the surface reads `enabled: false` off the status that comes back and says so. Turning it back
137
+ * on is a config edit — removing the key, or setting a threshold there — not a session command.
138
+ */
139
+ setSessionBudget(budget: TokenBudget): void;
140
+ /** The session override in force, or `null`. Read by `/autocompact` with no argument. */
141
+ get sessionOverride(): TokenBudget | null;
142
+ /** Whether the config off switch leaves anything to do at all. */
143
+ get enabled(): boolean;
144
+ /** The full picture — the one call `/status` makes. */
145
+ status(): Promise<AutocompactStatus>;
146
+ /** The number the guard compares against, or `null` for "never fire". */
147
+ threshold(): Promise<number | null>;
148
+ }
149
+ /**
150
+ * The absolute threshold `gth init` writes for a model whose window it resolved — a plain number,
151
+ * because the point of seeding is that the user can read the enforced value straight out of their
152
+ * config.
153
+ *
154
+ * Returns `null` when the window is unknown, and the caller then writes **no key at all**: seeding
155
+ * a guess would put a number in the user's config that looks chosen and was not, which is worse
156
+ * than the absent key that leaves the runtime default in charge.
157
+ */
158
+ export declare function seedAutocompactThreshold(window: number | null): number | null;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * EXT-161 — **the preventive compaction threshold: one number, one place it is decided.**
4
+ *
5
+ * `contextWindow.ts` answers "how big is the window". This module answers the question that
6
+ * actually gates a compaction: **at what prompt size do we fold the conversation, and who said
7
+ * so.** Three things can say so, in this order:
8
+ *
9
+ * 1. **the running session** — `/autocompact 300K`, which wins for the rest of the session;
10
+ * 2. **the user's config** — the `autocompact` key;
11
+ * 3. **the default** — derived from the resolved window by the guard, which is the only place that
12
+ * knows how many tokens are being held back for the answer.
13
+ *
14
+ * **Compaction is ON BY DEFAULT.** That is a ruling, and it is a deliberate exception to the rule
15
+ * that a default-on middleware must not touch `state.messages` — compaction changes what the model
16
+ * sees by construction, which is the whole feature. The exception is paid for the way the rule
17
+ * demands: the shape of the history a compaction leaves behind is pinned per provider by
18
+ * `compaction.ts`'s invariants (a)–(d), a compaction announces itself in the transcript, the
19
+ * resolved number and its provenance are readable with `/status`, and the off switch is one key —
20
+ * `autocompact: false`.
21
+ *
22
+ * **Why the provenance is carried rather than recomputed.** A threshold that is wrong is diagnosed
23
+ * by knowing where the number came from; a `/status` that reports a models.dev-derived number after
24
+ * a human typed `/autocompact 50000` would send the next diagnosis to the wrong place entirely. So
25
+ * a session override re-labels the provenance, and {@link AutocompactController} is the single
26
+ * object both the guard and `/status` read, over one memoised window resolution.
27
+ */
28
+ import { parseTokenBudget, resolveTokenBudget } from '#src/config/tokenBudget.js';
29
+ /**
30
+ * The share of a model's context window that `gth init` seeds as an explicit threshold.
31
+ *
32
+ * Init writes an **absolute number** derived from this rather than the percentage itself, because
33
+ * the point of seeding is that the user opens their config and sees the number that will actually
34
+ * be enforced. A percentage would leave them one lookup away from it.
35
+ *
36
+ * 0.8 leaves a fifth of the window for the answer and the tool round that follows it — comfortably
37
+ * more than the flat answer reserve on any real cloud window, which is what makes the seeded number
38
+ * the binding one rather than a decoration.
39
+ */
40
+ export const DEFAULT_AUTOCOMPACT_SEED_FRACTION = 0.8;
41
+ /**
42
+ * Read the `autocompact` config key.
43
+ *
44
+ * Defaulting happens **here, at the read site**, not in `DEFAULT_CONFIG`, so an absent key stays
45
+ * absent in the effective-config snapshot and the snapshot does not churn — the same placement
46
+ * `injectModelContext` and `toolLoopGuard` use.
47
+ *
48
+ * A malformed threshold raises the `TokenBudgetError` from the shared parser, which config
49
+ * validation turns into a field-scoped issue naming the offending text. It must never resolve to a
50
+ * number: `NaN`, `0` and 4097 are all thresholds the user cannot see and did not choose.
51
+ */
52
+ export function resolveAutocompactConfig(raw) {
53
+ if (raw === undefined || raw === null)
54
+ return { enabled: true, budget: null };
55
+ if (typeof raw === 'boolean')
56
+ return { enabled: raw, budget: null };
57
+ if (typeof raw === 'number' || typeof raw === 'string') {
58
+ return { enabled: true, budget: parseTokenBudget(raw) };
59
+ }
60
+ if (typeof raw === 'object') {
61
+ const value = raw;
62
+ const enabled = value.enabled !== false;
63
+ const budget = value.threshold === undefined || value.threshold === null
64
+ ? null
65
+ : parseTokenBudget(value.threshold);
66
+ return { enabled, budget };
67
+ }
68
+ return { enabled: true, budget: null };
69
+ }
70
+ /**
71
+ * **The single object the guard and `/status` both read.**
72
+ *
73
+ * Holds the session override — the write half of `/autocompact` — and resolves it against the
74
+ * config and the window on demand. It is mutable by design and the guard reads it through a
75
+ * closure rather than capturing a value, because `createContextGuardMiddleware` runs its factory
76
+ * once per session and its hook holds no state: a captured threshold could never be changed by a
77
+ * command typed later, which is precisely what `/autocompact` has to do.
78
+ */
79
+ export class AutocompactController {
80
+ options;
81
+ sessionBudget = null;
82
+ constructor(options) {
83
+ this.options = options;
84
+ }
85
+ /**
86
+ * Set the threshold for the rest of this session, overriding the config.
87
+ *
88
+ * Deliberately takes an already-parsed {@link TokenBudget} rather than raw text: parsing is the
89
+ * shared parser's job, and a second entry point that took a string would be a second place the
90
+ * grammar could drift.
91
+ *
92
+ * **Refused while the config has compaction off.** `autocompact: false` means nothing fires,
93
+ * whatever number is named, so recording the budget would only make the next {@link status}
94
+ * describe a threshold that can never trigger. Nothing is recorded and the status is unchanged;
95
+ * the surface reads `enabled: false` off the status that comes back and says so. Turning it back
96
+ * on is a config edit — removing the key, or setting a threshold there — not a session command.
97
+ */
98
+ setSessionBudget(budget) {
99
+ if (!this.options.config.enabled)
100
+ return;
101
+ this.sessionBudget = budget;
102
+ }
103
+ /** The session override in force, or `null`. Read by `/autocompact` with no argument. */
104
+ get sessionOverride() {
105
+ return this.sessionBudget;
106
+ }
107
+ /** Whether the config off switch leaves anything to do at all. */
108
+ get enabled() {
109
+ return this.options.config.enabled;
110
+ }
111
+ /** The full picture — the one call `/status` makes. */
112
+ async status() {
113
+ const reading = await this.options.window.read();
114
+ const budget = this.sessionBudget ?? this.options.config.budget;
115
+ const budgetOrigin = this.sessionBudget
116
+ ? 'session'
117
+ : this.options.config.budget
118
+ ? 'config'
119
+ : 'default';
120
+ if (!this.options.config.enabled) {
121
+ return {
122
+ enabled: false,
123
+ thresholdTokens: null,
124
+ thresholdOrigin: 'none',
125
+ window: reading.tokens,
126
+ windowOrigin: reading.origin,
127
+ budget,
128
+ };
129
+ }
130
+ // A named budget resolves against the window — which an absolute count does not need, so an
131
+ // explicit `300K` still fires on a model nothing knows the window of. A PERCENTAGE without a
132
+ // window cannot resolve, and falls through to the same "nothing fires" answer as no threshold
133
+ // at all rather than to a guess.
134
+ const named = budget ? resolveTokenBudget(budget, reading.tokens) : null;
135
+ if (named !== null) {
136
+ return {
137
+ enabled: true,
138
+ thresholdTokens: named,
139
+ thresholdOrigin: budgetOrigin === 'default' ? 'config' : budgetOrigin,
140
+ window: reading.tokens,
141
+ windowOrigin: reading.origin,
142
+ budget,
143
+ };
144
+ }
145
+ if (reading.tokens === null) {
146
+ return {
147
+ enabled: true,
148
+ thresholdTokens: null,
149
+ thresholdOrigin: 'none',
150
+ window: null,
151
+ windowOrigin: reading.origin,
152
+ budget,
153
+ };
154
+ }
155
+ return {
156
+ enabled: true,
157
+ thresholdTokens: this.options.defaultThreshold(reading.tokens),
158
+ thresholdOrigin: 'default',
159
+ window: reading.tokens,
160
+ windowOrigin: reading.origin,
161
+ budget,
162
+ };
163
+ }
164
+ /** The number the guard compares against, or `null` for "never fire". */
165
+ async threshold() {
166
+ return (await this.status()).thresholdTokens;
167
+ }
168
+ }
169
+ /**
170
+ * The absolute threshold `gth init` writes for a model whose window it resolved — a plain number,
171
+ * because the point of seeding is that the user can read the enforced value straight out of their
172
+ * config.
173
+ *
174
+ * Returns `null` when the window is unknown, and the caller then writes **no key at all**: seeding
175
+ * a guess would put a number in the user's config that looks chosen and was not, which is worse
176
+ * than the absent key that leaves the runtime default in charge.
177
+ */
178
+ export function seedAutocompactThreshold(window) {
179
+ if (window === null || !Number.isFinite(window) || window <= 0)
180
+ return null;
181
+ return Math.max(1, Math.floor(window * DEFAULT_AUTOCOMPACT_SEED_FRACTION));
182
+ }
183
+ //# sourceMappingURL=compactionThreshold.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compactionThreshold.js","sourceRoot":"","sources":["../../src/core/compactionThreshold.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,146 @@
1
+ import { type CatalogOptions, type ProviderCatalog } from '#src/providers/modelCatalog.js';
2
+ import type { ProviderId } from '#src/providers/modelDiscovery.js';
3
+ /**
4
+ * GS2-59 — default context window (`num_ctx`) for Ollama models. Ollama's OWN default is 4096, but
5
+ * gaunt-sloth's agentic prompt (system + full lean toolset + a tool result) already lands ~4000
6
+ * tokens; at 4096 a thinking model (e.g. gemma4:31b) spends its entire remaining budget on the
7
+ * reasoning field and emits EMPTY `content` on the turn after a tool executes — the GS2-59
8
+ * blank-answer regression. The OpenAI-compat `/v1` shim IGNORES `num_ctx`; the native `/api/chat`
9
+ * path honors it.
10
+ *
11
+ * 16384 is chosen as the largest window that is BOTH safely above the ~4000-token starvation point
12
+ * (4× headroom for reasoning + a few tool results) AND fits constrained consumer VRAM: Ollama
13
+ * preallocates the KV cache at `num_ctx`, so on a box where a large model already spills partly to
14
+ * CPU (e.g. a 19GB model on ~18GB of GPU), a 32768 cache tips the GPU allocation into an
15
+ * out-of-memory error. 16384 was verified live to run the agentic tool→synthesis turn on such a
16
+ * box; 32768 OOM'd it. Overridable per config via `llm.numCtx` — raise it if you have the VRAM and
17
+ * run long sessions, lower it on very tight hardware. NOTE: a per-request `num_ctx` overrides the
18
+ * daemon's `OLLAMA_CONTEXT_LENGTH`, so a user who tuned their server window higher should set
19
+ * `llm.numCtx` to match rather than rely on the server default.
20
+ *
21
+ * It lives here rather than in the provider module because two things now need it — the client the
22
+ * provider builds and the guard that has to know what that client will send — and a second copy is
23
+ * how the guard would come to reason about a window the request does not use.
24
+ */
25
+ export declare const DEFAULT_OLLAMA_NUM_CTX = 16384;
26
+ /**
27
+ * The model's context window in tokens, or `null` when it is not known.
28
+ *
29
+ * Asynchronous because a source may have to ask the provider. Called on every model call, so an
30
+ * implementation that does I/O is expected to memoise; {@link resolveContextWindowSource} does.
31
+ */
32
+ export type ContextWindowSource = () => Promise<number | null>;
33
+ /** A source that never knows — the honest answer for every provider no source is wired for. */
34
+ export declare const UNKNOWN_CONTEXT_WINDOW: ContextWindowSource;
35
+ /**
36
+ * The fields the window resolver reads off a chat model, without depending on its class.
37
+ *
38
+ * Structural rather than a `ChatOllama` import on purpose: the provider module loads
39
+ * `@langchain/ollama` dynamically so a session that never uses ollama never pays for it, and typing
40
+ * against the class here would undo that.
41
+ */
42
+ export interface OllamaLikeModel {
43
+ _llmType?: () => string;
44
+ numCtx?: number;
45
+ model?: string;
46
+ baseUrl?: string;
47
+ }
48
+ /**
49
+ * **The ollama window: what we send, capped by what the model can hold.**
50
+ *
51
+ * The effective window is `numCtx` — the number gaunt-sloth puts on every native `/api/chat`
52
+ * request, which overrides the daemon's own `OLLAMA_CONTEXT_LENGTH` — and never the daemon default,
53
+ * because the request is what decides. It is then capped by the model's own `context_length` from
54
+ * `/api/show`: asking for more than the model has does not buy more room, so guarding against the
55
+ * larger number would let exactly the truncation this guard exists to prevent happen anyway.
56
+ *
57
+ * **Fail-soft in one direction only.** No daemon, a slow daemon, an unreadable answer — the
58
+ * configured number stands, because that is still the number the request will carry. Nothing here
59
+ * throws, and nothing here degrades a known window to unknown.
60
+ */
61
+ export declare function createOllamaContextWindowSource(llm: OllamaLikeModel): ContextWindowSource;
62
+ /** Where a resolved context window came from — carried so `/status` can say, and a wrong one is
63
+ * diagnosable instead of mysterious. */
64
+ export type ContextWindowOrigin = 'ollama' | 'models.dev' | 'profile' | 'unknown';
65
+ /** A resolved window and its provenance. `tokens: null` means "not known", and never "zero". */
66
+ export interface ContextWindowReading {
67
+ tokens: number | null;
68
+ origin: ContextWindowOrigin;
69
+ }
70
+ /** How each origin is described to a user, in a sentence that says where to go to change it. */
71
+ export declare const CONTEXT_WINDOW_ORIGIN_LABELS: Readonly<Record<ContextWindowOrigin, string>>;
72
+ /**
73
+ * The model's window as the LangChain provider package declares it: `llm.profile.maxInputTokens`.
74
+ *
75
+ * A getter on `BaseLanguageModel` that the base class answers with `{}` and each provider package
76
+ * overrides, so an id its table has never heard of yields `undefined` rather than an error —
77
+ * measured: `gpt-4o-mini` gives 128000 and `mistralai/mistral-7b-instruct` gives nothing. Wrapped
78
+ * in a `try` because it is a getter on someone else's object and a throw here would take down a
79
+ * resolution that has a perfectly good answer to fall back to.
80
+ */
81
+ export declare function readProfileContextWindow(llm: unknown): number | null;
82
+ /** What {@link resolveContextWindow} needs beyond the model itself — all of it injectable. */
83
+ export interface ContextWindowResolutionOptions {
84
+ /**
85
+ * The gth provider namespace (`anthropic`, `google-genai`, …) — `config.modelProviderType`, NOT
86
+ * the model class's `_llmType()`. The two disagree exactly where it matters: `huggingface`
87
+ * reports `openai`, and both Gemini providers report `google`, so keying the catalog on the
88
+ * class's own label would read the wrong provider's slice or none at all.
89
+ */
90
+ providerId?: string;
91
+ /** The model id as models.dev keys it — `llm.model`, e.g. `claude-sonnet-4-5`. */
92
+ modelId?: string;
93
+ /**
94
+ * Options threaded to {@link getProviderCatalog} (cache dir, TTL, fetch impl) for hermetic tests.
95
+ *
96
+ * **`cacheOnly` defaults to `true` here and nowhere else.** This resolution sits in front of the
97
+ * first model call of a session, and a cold `api.json` fetch is a few MB behind a 10 s timeout —
98
+ * a delay the user would experience as the agent hanging before it said anything, to decide a
99
+ * threshold that already has a fallback. `gth init` passes `cacheOnly: false` because it is an
100
+ * explicit, interactive step that can afford to wait, and filling the cache there is what makes
101
+ * the runtime read a hit.
102
+ */
103
+ catalogOptions?: CatalogOptions;
104
+ /** Catalog reader override; {@link getProviderCatalog} when omitted. Injected by the tests. */
105
+ catalogReader?: (_providerId: ProviderId, _options: CatalogOptions) => Promise<ProviderCatalog | null>;
106
+ /** Profile reader override; {@link readProfileContextWindow} when omitted. Injected by the tests. */
107
+ profileReader?: (_llm: unknown) => number | null;
108
+ }
109
+ /**
110
+ * One resolution, read two ways: {@link ResolvedContextWindow.source} for the guard, which wants
111
+ * only the number, and {@link ResolvedContextWindow.read} for `/status`, which also wants the
112
+ * provenance.
113
+ *
114
+ * They are handed out as a pair from a single factory call, over a single memoised promise, so the
115
+ * number `/status` prints is by construction the number the guard enforced. Two independent
116
+ * resolvers could disagree — a stale catalog on one and a fresh fetch on the other — and a
117
+ * `/status` that describes a threshold nobody is using is worse than no `/status` line at all.
118
+ */
119
+ export interface ResolvedContextWindow {
120
+ /** The window in tokens, or `null` for unknown. Memoised; safe to call before every model call. */
121
+ source: ContextWindowSource;
122
+ /** The same resolution with its provenance attached. Shares the memoised promise. */
123
+ read: () => Promise<ContextWindowReading>;
124
+ }
125
+ /**
126
+ * **The one place a model is matched to a context window.** Tries ollama, then models.dev, then the
127
+ * LangChain profile, and answers `{ tokens: null, origin: 'unknown' }` when none of them knows.
128
+ *
129
+ * **There is deliberately no `?? DEFAULT` anywhere on this path.** One fallback turns every unknown
130
+ * window into a confident wrong number, which is the 4097 failure this file opens by naming.
131
+ *
132
+ * The whole resolution is memoised as a PROMISE, for the reason the ollama source already gives:
133
+ * this runs before every model call, the answer cannot change during a session, and holding the
134
+ * promise rather than the value means concurrent calls share one catalog read instead of racing to
135
+ * make several.
136
+ */
137
+ export declare function resolveContextWindow(llm: unknown, options?: ContextWindowResolutionOptions): ResolvedContextWindow;
138
+ /**
139
+ * The window as a bare number, for a caller that does not need the provenance.
140
+ *
141
+ * A thin wrapper over {@link resolveContextWindow} rather than a second implementation, so the two
142
+ * cannot answer differently. With no options it consults only the sources that need no
143
+ * configuration — ollama and the profile — which is why a plain object with neither still answers
144
+ * `null`.
145
+ */
146
+ export declare function resolveContextWindowSource(llm: unknown, options?: ContextWindowResolutionOptions): ContextWindowSource;