@gaunt-sloth/core 2.0.0-beta.5 → 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 (91) 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/conversationGrants.d.ts +60 -0
  22. package/dist/core/approvals/conversationGrants.js +77 -0
  23. package/dist/core/approvals/conversationGrants.js.map +1 -0
  24. package/dist/core/approvals/grants.d.ts +16 -0
  25. package/dist/core/approvals/grants.js +20 -5
  26. package/dist/core/approvals/grants.js.map +1 -1
  27. package/dist/core/compaction.d.ts +181 -0
  28. package/dist/core/compaction.js +293 -0
  29. package/dist/core/compaction.js.map +1 -0
  30. package/dist/core/compactionThreshold.d.ts +158 -0
  31. package/dist/core/compactionThreshold.js +183 -0
  32. package/dist/core/compactionThreshold.js.map +1 -0
  33. package/dist/core/contextWindow.d.ts +146 -0
  34. package/dist/core/contextWindow.js +256 -0
  35. package/dist/core/contextWindow.js.map +1 -0
  36. package/dist/core/exitOutputChannel.d.ts +51 -0
  37. package/dist/core/exitOutputChannel.js +65 -0
  38. package/dist/core/exitOutputChannel.js.map +1 -0
  39. package/dist/core/refusal.d.ts +17 -2
  40. package/dist/core/refusal.js +80 -14
  41. package/dist/core/refusal.js.map +1 -1
  42. package/dist/core/runStats.d.ts +1 -1
  43. package/dist/core/terminationNotice.d.ts +8 -0
  44. package/dist/core/terminationNotice.js +10 -4
  45. package/dist/core/terminationNotice.js.map +1 -1
  46. package/dist/core/terminationReason.d.ts +28 -0
  47. package/dist/core/terminationReason.js +27 -0
  48. package/dist/core/terminationReason.js.map +1 -1
  49. package/dist/core/types.d.ts +35 -1
  50. package/dist/core/types.js.map +1 -1
  51. package/dist/history/checkpointRetention.d.ts +279 -0
  52. package/dist/history/checkpointRetention.js +567 -0
  53. package/dist/history/checkpointRetention.js.map +1 -0
  54. package/dist/history/checkpointSaver.d.ts +93 -0
  55. package/dist/history/checkpointSaver.js +464 -0
  56. package/dist/history/checkpointSaver.js.map +1 -0
  57. package/dist/history/historyEnabled.d.ts +27 -0
  58. package/dist/history/historyEnabled.js +23 -0
  59. package/dist/history/historyEnabled.js.map +1 -0
  60. package/dist/history/historyFormat.d.ts +27 -0
  61. package/dist/history/historyFormat.js +125 -2
  62. package/dist/history/historyFormat.js.map +1 -1
  63. package/dist/history/historyStore.d.ts +61 -0
  64. package/dist/history/historyStore.js +180 -7
  65. package/dist/history/historyStore.js.map +1 -1
  66. package/dist/history/recordSession.d.ts +84 -22
  67. package/dist/history/recordSession.js +187 -12
  68. package/dist/history/recordSession.js.map +1 -1
  69. package/dist/history/sessionCheckpointer.d.ts +48 -0
  70. package/dist/history/sessionCheckpointer.js +200 -0
  71. package/dist/history/sessionCheckpointer.js.map +1 -0
  72. package/dist/index.d.ts +6 -0
  73. package/dist/index.js +8 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/providers/modelCatalog.d.ts +14 -0
  76. package/dist/providers/modelCatalog.js +4 -0
  77. package/dist/providers/modelCatalog.js.map +1 -1
  78. package/dist/providers/modelDiscovery.d.ts +3 -1
  79. package/dist/providers/modelDiscovery.js +22 -8
  80. package/dist/providers/modelDiscovery.js.map +1 -1
  81. package/dist/providers/ollama.js +3 -19
  82. package/dist/providers/ollama.js.map +1 -1
  83. package/dist/runtime/conversation.js +7 -1
  84. package/dist/runtime/conversation.js.map +1 -1
  85. package/dist/runtime/singleShot.js +6 -1
  86. package/dist/runtime/singleShot.js.map +1 -1
  87. package/dist/utils/consoleUtils.d.ts +77 -0
  88. package/dist/utils/consoleUtils.js +81 -0
  89. package/dist/utils/consoleUtils.js.map +1 -1
  90. package/package.json +2 -2
  91. package/schema/gsloth-config.schema.json +33 -14
@@ -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;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * EXT-160 — **how many tokens the model in front of us will actually accept.**
4
+ *
5
+ * The pre-call context guard needs one number: the window, in tokens. This module resolves it, and
6
+ * its contract is the part that matters — **`null` means "unknown", and an unknown window never
7
+ * triggers anything.** A guard that guesses a window is worse than no guard: LangChain's own
8
+ * overflow fallback guesses 4097 for anything it does not recognise, which on a 262144-token local
9
+ * model would compact a conversation that had all the room in the world. So every source here
10
+ * either knows or says it does not.
11
+ *
12
+ * The source is **injectable and per-provider**, and there are three of them, tried in a fixed
13
+ * order by {@link resolveContextWindow}:
14
+ *
15
+ * 1. **ollama** — the number this session will actually put on the request (`num_ctx`), capped by
16
+ * the model's own `context_length`. Ollama is the only provider that cannot report an overflow
17
+ * at all: it silently drops the oldest tokens to fit and answers from the remainder, so knowing
18
+ * the number beforehand is the only defence.
19
+ * 2. **models.dev** ([[EXT-161]]) — the cloud catalog's `limit.context`, read through the
20
+ * cache-first slice `providers/modelCatalog.ts` already maintains.
21
+ * 3. **the LangChain profile** ([[EXT-161]]) — `llm.profile.maxInputTokens`, a backstop.
22
+ *
23
+ * **models.dev outranks the profile, and the order is a ruling rather than a preference.** The
24
+ * profile is a table compiled into a provider package, so it moves only when that package is
25
+ * republished and we bump it, while the catalog refreshes on a 24h TTL. That makes a profile
26
+ * **wrong as well as absent**: measured on this repo's pinned packages, `deepseek-chat` reports a
27
+ * 1,000,000-token window from `@langchain/deepseek`'s own table. Wrong-and-confident is the failure
28
+ * this order avoids — an overstated window means no preventive compaction at all, which is exactly
29
+ * the case the reactive seam then has to catch.
30
+ *
31
+ * **A model none of the three knows resolves to `null`, and `null` triggers nothing.** There is
32
+ * deliberately no default anywhere in this chain: LangChain's own overflow fallback guesses 4097
33
+ * for an unrecognised model, which on a 262144-token local model would compact a conversation that
34
+ * had all the room in the world, and on an unknown cloud model would compact at roughly 3.3k tokens
35
+ * with nothing on screen to say why.
36
+ */
37
+ import { debugLog } from '#src/utils/debugUtils.js';
38
+ import { getProviderCatalog, } from '#src/providers/modelCatalog.js';
39
+ /**
40
+ * GS2-59 — default context window (`num_ctx`) for Ollama models. Ollama's OWN default is 4096, but
41
+ * gaunt-sloth's agentic prompt (system + full lean toolset + a tool result) already lands ~4000
42
+ * tokens; at 4096 a thinking model (e.g. gemma4:31b) spends its entire remaining budget on the
43
+ * reasoning field and emits EMPTY `content` on the turn after a tool executes — the GS2-59
44
+ * blank-answer regression. The OpenAI-compat `/v1` shim IGNORES `num_ctx`; the native `/api/chat`
45
+ * path honors it.
46
+ *
47
+ * 16384 is chosen as the largest window that is BOTH safely above the ~4000-token starvation point
48
+ * (4× headroom for reasoning + a few tool results) AND fits constrained consumer VRAM: Ollama
49
+ * preallocates the KV cache at `num_ctx`, so on a box where a large model already spills partly to
50
+ * CPU (e.g. a 19GB model on ~18GB of GPU), a 32768 cache tips the GPU allocation into an
51
+ * out-of-memory error. 16384 was verified live to run the agentic tool→synthesis turn on such a
52
+ * box; 32768 OOM'd it. Overridable per config via `llm.numCtx` — raise it if you have the VRAM and
53
+ * run long sessions, lower it on very tight hardware. NOTE: a per-request `num_ctx` overrides the
54
+ * daemon's `OLLAMA_CONTEXT_LENGTH`, so a user who tuned their server window higher should set
55
+ * `llm.numCtx` to match rather than rely on the server default.
56
+ *
57
+ * It lives here rather than in the provider module because two things now need it — the client the
58
+ * provider builds and the guard that has to know what that client will send — and a second copy is
59
+ * how the guard would come to reason about a window the request does not use.
60
+ */
61
+ export const DEFAULT_OLLAMA_NUM_CTX = 16384;
62
+ /** How long the daemon gets to answer `/api/show` before the cap is treated as unknown. */
63
+ const SHOW_TIMEOUT_MS = 3000;
64
+ /** A source that never knows — the honest answer for every provider no source is wired for. */
65
+ export const UNKNOWN_CONTEXT_WINDOW = async () => null;
66
+ /** Whether a resolved chat model is the native `ChatOllama` client (`_llmType()` is `'ollama'`). */
67
+ function isOllamaModel(llm) {
68
+ try {
69
+ const type = llm?._llmType?.();
70
+ return type === 'ollama';
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /**
77
+ * The model's own maximum from the daemon: `/api/show` reports `model_info` keyed by architecture,
78
+ * e.g. `gemma4.context_length`. Returns `null` on any failure — no daemon, a model the daemon does
79
+ * not have, a timeout, a response shaped differently by a future ollama — because a cap we could
80
+ * not read is not a cap of zero.
81
+ */
82
+ async function readOllamaModelCap(baseUrl, model) {
83
+ try {
84
+ const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/show`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({ model }),
88
+ signal: AbortSignal.timeout(SHOW_TIMEOUT_MS),
89
+ });
90
+ if (!response.ok)
91
+ return null;
92
+ const body = (await response.json());
93
+ const info = body?.model_info;
94
+ if (!info || typeof info !== 'object')
95
+ return null;
96
+ // Prefer the architecture the daemon itself names; fall back to any `*.context_length` key so a
97
+ // model whose `general.architecture` is missing still reports its window.
98
+ const architecture = info['general.architecture'];
99
+ const keys = typeof architecture === 'string' && architecture.length > 0
100
+ ? [`${architecture}.context_length`]
101
+ : Object.keys(info).filter((key) => key.endsWith('.context_length'));
102
+ for (const key of keys) {
103
+ const value = info[key];
104
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0)
105
+ return value;
106
+ }
107
+ return null;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ /**
114
+ * **The ollama window: what we send, capped by what the model can hold.**
115
+ *
116
+ * The effective window is `numCtx` — the number gaunt-sloth puts on every native `/api/chat`
117
+ * request, which overrides the daemon's own `OLLAMA_CONTEXT_LENGTH` — and never the daemon default,
118
+ * because the request is what decides. It is then capped by the model's own `context_length` from
119
+ * `/api/show`: asking for more than the model has does not buy more room, so guarding against the
120
+ * larger number would let exactly the truncation this guard exists to prevent happen anyway.
121
+ *
122
+ * **Fail-soft in one direction only.** No daemon, a slow daemon, an unreadable answer — the
123
+ * configured number stands, because that is still the number the request will carry. Nothing here
124
+ * throws, and nothing here degrades a known window to unknown.
125
+ */
126
+ export function createOllamaContextWindowSource(llm) {
127
+ const configured = typeof llm.numCtx === 'number' && llm.numCtx > 0 ? llm.numCtx : undefined;
128
+ // `providers/ollama.ts` always sets `numCtx`, so the default below is reached only by a chat model
129
+ // a JS config built by hand.
130
+ const requested = configured ?? DEFAULT_OLLAMA_NUM_CTX;
131
+ const baseUrl = typeof llm.baseUrl === 'string' ? llm.baseUrl : undefined;
132
+ const model = typeof llm.model === 'string' ? llm.model : undefined;
133
+ // Memoised: the window cannot change during a session, and the guard runs before EVERY model
134
+ // call. A per-call `/api/show` would put a network round trip in front of each one. Held as the
135
+ // PROMISE so concurrent calls share the one request rather than racing to make several.
136
+ let pending;
137
+ return async () => {
138
+ if (!baseUrl || !model)
139
+ return requested;
140
+ pending ??= readOllamaModelCap(baseUrl, model);
141
+ const cap = await pending;
142
+ if (cap === null)
143
+ return requested;
144
+ const effective = Math.min(requested, cap);
145
+ if (effective !== requested) {
146
+ debugLog(`Ollama context window: requested num_ctx ${requested} exceeds ${model}'s own ` +
147
+ `${cap}; guarding against ${effective}.`);
148
+ }
149
+ return effective;
150
+ };
151
+ }
152
+ /** How each origin is described to a user, in a sentence that says where to go to change it. */
153
+ export const CONTEXT_WINDOW_ORIGIN_LABELS = {
154
+ ollama: "this session's num_ctx, capped by the model's own context length",
155
+ 'models.dev': 'the models.dev catalog',
156
+ profile: "the provider package's built-in model profile",
157
+ unknown: 'nowhere — no source knows this model, so nothing is compacted preventively',
158
+ };
159
+ /**
160
+ * The model's window as the LangChain provider package declares it: `llm.profile.maxInputTokens`.
161
+ *
162
+ * A getter on `BaseLanguageModel` that the base class answers with `{}` and each provider package
163
+ * overrides, so an id its table has never heard of yields `undefined` rather than an error —
164
+ * measured: `gpt-4o-mini` gives 128000 and `mistralai/mistral-7b-instruct` gives nothing. Wrapped
165
+ * in a `try` because it is a getter on someone else's object and a throw here would take down a
166
+ * resolution that has a perfectly good answer to fall back to.
167
+ */
168
+ export function readProfileContextWindow(llm) {
169
+ try {
170
+ const profile = llm?.profile;
171
+ const max = profile?.maxInputTokens;
172
+ return typeof max === 'number' && Number.isFinite(max) && max > 0 ? max : null;
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ /**
179
+ * **The one place a model is matched to a context window.** Tries ollama, then models.dev, then the
180
+ * LangChain profile, and answers `{ tokens: null, origin: 'unknown' }` when none of them knows.
181
+ *
182
+ * **There is deliberately no `?? DEFAULT` anywhere on this path.** One fallback turns every unknown
183
+ * window into a confident wrong number, which is the 4097 failure this file opens by naming.
184
+ *
185
+ * The whole resolution is memoised as a PROMISE, for the reason the ollama source already gives:
186
+ * this runs before every model call, the answer cannot change during a session, and holding the
187
+ * promise rather than the value means concurrent calls share one catalog read instead of racing to
188
+ * make several.
189
+ */
190
+ export function resolveContextWindow(llm, options = {}) {
191
+ const ollamaSource = isOllamaModel(llm) ? createOllamaContextWindowSource(llm) : null;
192
+ const catalogReader = options.catalogReader ?? getProviderCatalog;
193
+ const profileReader = options.profileReader ?? readProfileContextWindow;
194
+ const providerId = options.providerId?.trim();
195
+ const modelId = options.modelId?.trim();
196
+ let pending;
197
+ const resolve = async () => {
198
+ // 1. Ollama — the number the request will actually carry. It is asked first and not merely
199
+ // preferred: models.dev deliberately has no ollama entry (local models have no catalog
200
+ // row), so for ollama there is nothing below this to fall through to.
201
+ if (ollamaSource) {
202
+ try {
203
+ const tokens = await ollamaSource();
204
+ if (tokens !== null && Number.isFinite(tokens) && tokens > 0) {
205
+ return { tokens, origin: 'ollama' };
206
+ }
207
+ }
208
+ catch {
209
+ /* the ollama source is documented never to throw; a stub still might */
210
+ }
211
+ }
212
+ // 2. models.dev — RULED to outrank the profile. See the module docblock for why.
213
+ if (providerId && modelId) {
214
+ try {
215
+ const catalog = await catalogReader(providerId, {
216
+ cacheOnly: true,
217
+ ...options.catalogOptions,
218
+ });
219
+ const context = catalog?.models?.[modelId]?.limit?.context;
220
+ if (typeof context === 'number' && Number.isFinite(context) && context > 0) {
221
+ return { tokens: context, origin: 'models.dev' };
222
+ }
223
+ }
224
+ catch {
225
+ // `getProviderCatalog` never throws by contract (catalog availability must never block a
226
+ // model), so this catches an injected stub only — but a resolution that fell over here
227
+ // would take the profile backstop down with it, which is the opposite of degrading well.
228
+ }
229
+ }
230
+ // 3. The profile — a backstop, recorded as such so a wrong threshold is diagnosable.
231
+ try {
232
+ const profile = profileReader(llm);
233
+ if (profile !== null && Number.isFinite(profile) && profile > 0) {
234
+ return { tokens: profile, origin: 'profile' };
235
+ }
236
+ }
237
+ catch {
238
+ /* see readProfileContextWindow: someone else's getter */
239
+ }
240
+ return { tokens: null, origin: 'unknown' };
241
+ };
242
+ const read = () => (pending ??= resolve());
243
+ return { read, source: async () => (await read()).tokens };
244
+ }
245
+ /**
246
+ * The window as a bare number, for a caller that does not need the provenance.
247
+ *
248
+ * A thin wrapper over {@link resolveContextWindow} rather than a second implementation, so the two
249
+ * cannot answer differently. With no options it consults only the sources that need no
250
+ * configuration — ollama and the profile — which is why a plain object with neither still answers
251
+ * `null`.
252
+ */
253
+ export function resolveContextWindowSource(llm, options = {}) {
254
+ return resolveContextWindow(llm, options).source;
255
+ }
256
+ //# sourceMappingURL=contextWindow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contextWindow.js","sourceRoot":"","sources":["../../src/core/contextWindow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @module exitOutputChannel
3
+ * TUI-C56 — the queue for output that must OUTLIVE the session's own screen.
4
+ *
5
+ * TUI-C48 put the whole interactive session in the terminal's alternate screen. Ink treats
6
+ * alternate-screen teardown output as disposable by design: on unmount it restores the primary
7
+ * buffer without replaying prior frames, so everything the session painted — every notice, every
8
+ * answer — goes with the alternate buffer when the session ends. That is exactly what the
9
+ * full-screen surface is for, and it is the right default for a conversation.
10
+ *
11
+ * It is the wrong default for the handful of things a user must still have AFTER the session:
12
+ * a path they now have to go and open. The first of those is the `/debug-dump` archive, whose
13
+ * in-frame notice is genuinely useful mid-session and completely gone the moment the user exits —
14
+ * leaving them holding a diagnostic bundle they cannot find, with nothing having errored.
15
+ *
16
+ * So this is a channel, not a special case: a producer anywhere in the codebase can DEFER one
17
+ * block of text, and the surface that owns the terminal drains it once the terminal is its
18
+ * owner's again. Two halves, deliberately split:
19
+ *
20
+ * - **This module is the queue and nothing else.** No stream, no formatting, no policy about
21
+ * when a line is worth printing. That keeps it surface-agnostic and directly testable.
22
+ * - **The surface owns the write.** Only the surface knows when its terminal is back (for the
23
+ * Ink TUI: after `waitUntilExit()` has resolved, which is after Ink has left the alternate
24
+ * screen) and whether writing is appropriate at all (a piped run must not get an extra line
25
+ * its caller did not ask for). See `writeDeferredExitOutput` in the TUI session module.
26
+ *
27
+ * A surface whose output already survives its own exit — the readline (`--no-tui`) session, which
28
+ * never owned an alternate screen — simply never drains. Deferring is then a no-op that costs one
29
+ * array entry and prints nothing, which is what keeps the producers surface-agnostic: `/debug-dump`
30
+ * is one command with one behaviour, and the two surfaces differ only in whether the re-print is
31
+ * needed at all.
32
+ */
33
+ /**
34
+ * Defer one block of text until the surface hands the terminal back.
35
+ *
36
+ * The text should stand on its own: it lands on a restored screen with none of the session's
37
+ * framing around it and possibly a long scrollback above it, so a bare value with no label is not
38
+ * enough for a reader to know what they are looking at.
39
+ */
40
+ export declare function deferExitOutput(text: string): void;
41
+ /**
42
+ * Take everything deferred so far, emptying the queue. Draining is destructive so a second drain
43
+ * — a surface with more than one exit path reaching the drain twice — cannot double-print.
44
+ */
45
+ export declare function drainExitOutput(): string[];
46
+ /**
47
+ * Discard anything deferred without emitting it. A surface calls this as it starts, so a block
48
+ * deferred by something earlier in the same process can never surface at the end of a session
49
+ * that had nothing to do with it.
50
+ */
51
+ export declare function clearExitOutput(): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @module exitOutputChannel
3
+ * TUI-C56 — the queue for output that must OUTLIVE the session's own screen.
4
+ *
5
+ * TUI-C48 put the whole interactive session in the terminal's alternate screen. Ink treats
6
+ * alternate-screen teardown output as disposable by design: on unmount it restores the primary
7
+ * buffer without replaying prior frames, so everything the session painted — every notice, every
8
+ * answer — goes with the alternate buffer when the session ends. That is exactly what the
9
+ * full-screen surface is for, and it is the right default for a conversation.
10
+ *
11
+ * It is the wrong default for the handful of things a user must still have AFTER the session:
12
+ * a path they now have to go and open. The first of those is the `/debug-dump` archive, whose
13
+ * in-frame notice is genuinely useful mid-session and completely gone the moment the user exits —
14
+ * leaving them holding a diagnostic bundle they cannot find, with nothing having errored.
15
+ *
16
+ * So this is a channel, not a special case: a producer anywhere in the codebase can DEFER one
17
+ * block of text, and the surface that owns the terminal drains it once the terminal is its
18
+ * owner's again. Two halves, deliberately split:
19
+ *
20
+ * - **This module is the queue and nothing else.** No stream, no formatting, no policy about
21
+ * when a line is worth printing. That keeps it surface-agnostic and directly testable.
22
+ * - **The surface owns the write.** Only the surface knows when its terminal is back (for the
23
+ * Ink TUI: after `waitUntilExit()` has resolved, which is after Ink has left the alternate
24
+ * screen) and whether writing is appropriate at all (a piped run must not get an extra line
25
+ * its caller did not ask for). See `writeDeferredExitOutput` in the TUI session module.
26
+ *
27
+ * A surface whose output already survives its own exit — the readline (`--no-tui`) session, which
28
+ * never owned an alternate screen — simply never drains. Deferring is then a no-op that costs one
29
+ * array entry and prints nothing, which is what keeps the producers surface-agnostic: `/debug-dump`
30
+ * is one command with one behaviour, and the two surfaces differ only in whether the re-print is
31
+ * needed at all.
32
+ */
33
+ /**
34
+ * Blocks of deferred text, in the order they were deferred. Module-level, like the single
35
+ * subscriber in `toolOutputChannel`: one process hosts at most one interactive session, and no
36
+ * server surface reaches a producer (the slash-command registry is built only by the two
37
+ * interactive sessions).
38
+ */
39
+ const deferred = [];
40
+ /**
41
+ * Defer one block of text until the surface hands the terminal back.
42
+ *
43
+ * The text should stand on its own: it lands on a restored screen with none of the session's
44
+ * framing around it and possibly a long scrollback above it, so a bare value with no label is not
45
+ * enough for a reader to know what they are looking at.
46
+ */
47
+ export function deferExitOutput(text) {
48
+ deferred.push(text);
49
+ }
50
+ /**
51
+ * Take everything deferred so far, emptying the queue. Draining is destructive so a second drain
52
+ * — a surface with more than one exit path reaching the drain twice — cannot double-print.
53
+ */
54
+ export function drainExitOutput() {
55
+ return deferred.splice(0, deferred.length);
56
+ }
57
+ /**
58
+ * Discard anything deferred without emitting it. A surface calls this as it starts, so a block
59
+ * deferred by something earlier in the same process can never surface at the end of a session
60
+ * that had nothing to do with it.
61
+ */
62
+ export function clearExitOutput() {
63
+ deferred.length = 0;
64
+ }
65
+ //# sourceMappingURL=exitOutputChannel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exitOutputChannel.js","sourceRoot":"","sources":["../../src/core/exitOutputChannel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH;;;;;GAKG;AACH,MAAM,QAAQ,GAAa,EAAE,CAAC;AAE9B;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;AAC7C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe;IAC7B,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;AACtB,CAAC"}
@@ -30,8 +30,15 @@ import type { GthTerminationClassification } from '#src/core/terminationReason.j
30
30
  /** One detected refusal, normalized across providers. */
31
31
  export interface RefusalInfo {
32
32
  /** Best-effort provider family the signal came from (for logging / the surfaced message). */
33
- provider: 'openai' | 'anthropic' | 'bedrock' | 'unknown';
34
- /** The raw stop/finish reason token that flagged the refusal (e.g. `content_filter`). */
33
+ provider: 'openai' | 'anthropic' | 'bedrock' | 'google' | 'unknown';
34
+ /**
35
+ * The stop/finish reason token that flagged the refusal, lower-cased (e.g. `content_filter`).
36
+ *
37
+ * Lower-cased rather than raw because the token's case is a per-provider spelling, not a fact:
38
+ * Gemini shouts `SAFETY` where OpenAI writes `content_filter`, and this value is matched, logged
39
+ * and carried into the taxonomy's `detail` — the same normalization
40
+ * {@link detectOutputTruncation} already applies to its own token.
41
+ */
35
42
  reason: string;
36
43
  /** Any model-provided explanation text (empty string when the refusal carried none). */
37
44
  explanation: string;
@@ -42,6 +49,9 @@ export interface RefusalInfo {
42
49
  * reason indicates a content-policy refusal, else `null`. Defensive: any non-message / unexpected
43
50
  * shape yields `null`, so a normal turn is never mistaken for a refusal.
44
51
  *
52
+ * Reasons are compared lower-cased, because the case is a provider's spelling and not a fact —
53
+ * Gemini shouts `SAFETY` where OpenAI writes `content_filter`.
54
+ *
45
55
  * Covered shapes:
46
56
  * - OpenAI-family `finish_reason: 'content_filter'` (also under `additional_kwargs`).
47
57
  * - Anthropic `stop_reason: 'refusal'`.
@@ -51,6 +61,11 @@ export interface RefusalInfo {
51
61
  * - Bedrock Converse content filter: `stopReason`/`stop_reason`/`finish_reason` ===
52
62
  * `'content_filtered'` (EXT-41 — a distinct `StopReason` enum value from `guardrail_intervened`
53
63
  * that was previously mapped to `null`, i.e. a silent empty turn / false negative).
64
+ * - Gemini's safety reasons ({@link GEMINI_REFUSAL_REASONS}) under **camelCase `finishReason`**
65
+ * (CFG-41). That key is the half that has to come first: `@langchain/google`'s streamed chunk
66
+ * puts `model_provider` and nothing else in `response_metadata` and the reason only in
67
+ * `additional_kwargs.finishReason`, so a token added without the key would sit on a branch that
68
+ * never ran for Gemini.
54
69
  */
55
70
  export declare function detectRefusal(message: unknown): RefusalInfo | null;
56
71
  /**