@nemesis-oss/ollama-sdk 1.10.0 → 1.11.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/index.js CHANGED
@@ -339,6 +339,23 @@ var OllamaAgentMaxIterationsError = class extends OllamaClientError {
339
339
  this.maxIterations = options.maxIterations;
340
340
  }
341
341
  };
342
+ var OllamaAgentToolLoopError = class extends OllamaClientError {
343
+ /** Name of the tool whose identical call would exceed the repeat budget. */
344
+ toolName;
345
+ /** How many times this exact call would have executed including the rejected one. */
346
+ repeatedExecutions;
347
+ /** The configured per-signature execution budget. */
348
+ maxRepeatedToolCalls;
349
+ /** Canonical `name(args)` signature of the repeated call (very large payloads truncated). */
350
+ signature;
351
+ constructor(message, options) {
352
+ super(message, { ...options, code: "agent_tool_loop_detected", retryable: false });
353
+ this.toolName = options.toolName;
354
+ this.repeatedExecutions = options.repeatedExecutions;
355
+ this.maxRepeatedToolCalls = options.maxRepeatedToolCalls;
356
+ this.signature = options.signature;
357
+ }
358
+ };
342
359
  var OllamaMcpError = class extends OllamaClientError {
343
360
  mcpMethod;
344
361
  toolName;
@@ -638,6 +655,15 @@ var EndpointRegistry = class {
638
655
  }
639
656
  };
640
657
 
658
+ // src/transport/url.ts
659
+ function joinUrlPath(baseUrl, path) {
660
+ const base = baseUrl.replace(/\/+$/, "");
661
+ const tail = path.replace(/^\/+/, "");
662
+ if (tail === "") return base;
663
+ if (base === "") return `/${tail}`;
664
+ return `${base}/${tail}`;
665
+ }
666
+
641
667
  // src/middleware.ts
642
668
  function composeMiddleware(middlewares, finalHandler) {
643
669
  return function runPipeline(initialRequest) {
@@ -1190,7 +1216,7 @@ var HttpClient = class {
1190
1216
  }
1191
1217
  }
1192
1218
  async request(options) {
1193
- const url = `${this.baseUrl}${options.path}`;
1219
+ const url = joinUrlPath(this.baseUrl, options.path);
1194
1220
  const method = options.method ?? (options.body !== void 0 || options.rawBody !== void 0 ? "POST" : "GET");
1195
1221
  const headers = this.buildHeaders(options.headers);
1196
1222
  if (options.rawBody !== void 0 && !options.headers?.["Content-Type"]) {
@@ -1250,7 +1276,7 @@ var HttpClient = class {
1250
1276
  * can decode their provider-specific event payloads without duplicating transport logic.
1251
1277
  */
1252
1278
  async requestSseStream(options) {
1253
- const url = `${this.baseUrl}${options.path}`;
1279
+ const url = joinUrlPath(this.baseUrl, options.path);
1254
1280
  const method = options.method ?? "POST";
1255
1281
  const headers = {
1256
1282
  ...this.buildHeaders(options.headers),
@@ -1317,7 +1343,7 @@ var HttpClient = class {
1317
1343
  );
1318
1344
  }
1319
1345
  async requestStream(options) {
1320
- const url = `${this.baseUrl}${options.path}`;
1346
+ const url = joinUrlPath(this.baseUrl, options.path);
1321
1347
  const method = options.method ?? "POST";
1322
1348
  const headers = this.buildHeaders(options.headers);
1323
1349
  return withSpan(
@@ -2158,6 +2184,151 @@ function createTimeoutSignal(timeoutMs, userSignal) {
2158
2184
  return { signal: controller.signal, cancel };
2159
2185
  }
2160
2186
 
2187
+ // src/context-safety.ts
2188
+ var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
2189
+ var IMAGE_TOKEN_ESTIMATE = 300;
2190
+ var CHARS_PER_TOKEN = 4;
2191
+ function isCjkCodePoint(code) {
2192
+ return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
2193
+ code >= 13312 && code <= 19903 || // CJK Extension A
2194
+ code >= 12352 && code <= 12543 || // Hiragana + Katakana
2195
+ code >= 44032 && code <= 55215 || // Hangul syllables
2196
+ code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
2197
+ code >= 65280 && code <= 65519;
2198
+ }
2199
+ function estimateTokens(text) {
2200
+ if (text.length === 0) return 0;
2201
+ let cjk = 0;
2202
+ let other = 0;
2203
+ for (const char of text) {
2204
+ if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
2205
+ else other += 1;
2206
+ }
2207
+ return Math.ceil(cjk + other / CHARS_PER_TOKEN);
2208
+ }
2209
+ var MESSAGE_OVERHEAD_TOKENS = 4;
2210
+ function estimateMessageTokens(message) {
2211
+ let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
2212
+ if (message.thinking !== void 0) total += estimateTokens(message.thinking);
2213
+ if (message.tool_calls !== void 0) {
2214
+ for (const call of message.tool_calls) {
2215
+ total += estimateTokens(call.function.name);
2216
+ total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
2217
+ }
2218
+ }
2219
+ if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
2220
+ return total;
2221
+ }
2222
+ function estimateChatRequestTokens(req) {
2223
+ let total = 0;
2224
+ for (const message of req.messages) total += estimateMessageTokens(message);
2225
+ if (req.tools !== void 0) {
2226
+ for (const tool of req.tools) {
2227
+ total += estimateTokens(tool.function.description ?? "");
2228
+ total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
2229
+ }
2230
+ }
2231
+ return total;
2232
+ }
2233
+ function estimateGenerateRequestTokens(req) {
2234
+ let total = estimateTokens(req.prompt ?? "");
2235
+ if (req.system !== void 0) total += estimateTokens(req.system);
2236
+ if (req.template !== void 0) total += estimateTokens(req.template);
2237
+ if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
2238
+ return total;
2239
+ }
2240
+ function checkChatContext(req, options = {}) {
2241
+ return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
2242
+ }
2243
+ function checkGenerateContext(req, options = {}) {
2244
+ return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
2245
+ }
2246
+ function check(estimatedPromptTokens, requestNumCtx, options) {
2247
+ const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
2248
+ if (requestNumCtx !== void 0) {
2249
+ return {
2250
+ estimatedPromptTokens,
2251
+ effectiveContextLength: requestNumCtx,
2252
+ windowSource: "request",
2253
+ exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
2254
+ };
2255
+ }
2256
+ if (options.defaultContextLength !== void 0) {
2257
+ return {
2258
+ estimatedPromptTokens,
2259
+ effectiveContextLength: options.defaultContextLength,
2260
+ windowSource: "client-default",
2261
+ exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
2262
+ };
2263
+ }
2264
+ return {
2265
+ estimatedPromptTokens,
2266
+ effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
2267
+ windowSource: "server-fallback",
2268
+ exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
2269
+ };
2270
+ }
2271
+ var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
2272
+ function contextWarningMessage(check2, kind) {
2273
+ const where = `OllamaClient.${kind}()`;
2274
+ const estimate = `~${check2.estimatedPromptTokens} tokens`;
2275
+ if (check2.windowSource === "request") {
2276
+ return `${where}: estimated prompt size ${estimate} is close to or beyond the request's context window (num_ctx=${check2.effectiveContextLength}). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`options.num_ctx\` or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2277
+ }
2278
+ if (check2.windowSource === "client-default") {
2279
+ return `${where}: estimated prompt size ${estimate} is close to or beyond the client's \`defaultContextLength\` (${check2.effectiveContextLength}, injected as \`num_ctx\`). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`defaultContextLength\`, set \`options.num_ctx\` per request, or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2280
+ }
2281
+ return `${where}: estimated prompt size ${estimate} outright exceeds Ollama's conservative server-default context window (${OLLAMA_FALLBACK_CONTEXT_LENGTH}\u20134096 tokens when \`num_ctx\` is unset \u2014 model-dependent). Unless the model's default is larger, the server will silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Set \`options.num_ctx\` (or the client's \`defaultContextLength\`) to an explicit window to make truncation visible and controllable. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2282
+ }
2283
+
2284
+ // src/context-discovery.ts
2285
+ function extractParameterNumCtx(parameters) {
2286
+ if (!parameters) return void 0;
2287
+ const match = /^[ \t]*num_ctx[ \t]+(\d+)\s*$/m.exec(parameters);
2288
+ if (match === null) return void 0;
2289
+ const value = Number.parseInt(match[1], 10);
2290
+ return Number.isFinite(value) && value > 0 ? value : void 0;
2291
+ }
2292
+ function findRunningModelContextLength(psModels, model) {
2293
+ for (const entry of psModels) {
2294
+ const exact = entry.name === model || entry.model === model;
2295
+ const taglessPrefix = !model.includes(":") && (entry.name.startsWith(`${model}:`) || entry.model.startsWith(`${model}:`));
2296
+ if (exact || taglessPrefix) {
2297
+ return typeof entry.context_length === "number" && Number.isFinite(entry.context_length) && entry.context_length > 0 ? entry.context_length : void 0;
2298
+ }
2299
+ }
2300
+ return void 0;
2301
+ }
2302
+ function positiveInt(value) {
2303
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2304
+ }
2305
+ function resolveContextLength(signals) {
2306
+ const running = positiveInt(signals.running);
2307
+ const parameter = positiveInt(signals.parameter);
2308
+ const native = positiveInt(signals.native);
2309
+ if (running !== void 0) {
2310
+ return {
2311
+ contextLength: running,
2312
+ source: "running",
2313
+ ...parameter !== void 0 ? { parameterContextLength: parameter } : {},
2314
+ ...native !== void 0 ? { nativeContextLength: native } : {},
2315
+ runningContextLength: running
2316
+ };
2317
+ }
2318
+ if (parameter !== void 0) {
2319
+ return {
2320
+ contextLength: parameter,
2321
+ source: "parameters",
2322
+ parameterContextLength: parameter,
2323
+ ...native !== void 0 ? { nativeContextLength: native } : {}
2324
+ };
2325
+ }
2326
+ if (native !== void 0) {
2327
+ return { contextLength: native, source: "model-info", nativeContextLength: native };
2328
+ }
2329
+ return { contextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH, source: "fallback" };
2330
+ }
2331
+
2161
2332
  // src/keep-alive.ts
2162
2333
  var KEEP_ALIVE_UNLOAD = 0;
2163
2334
  var KEEP_ALIVE_INDEFINITE = -1;
@@ -2271,9 +2442,66 @@ var ModelsClient = class {
2271
2442
  { ...request, singleEndpoint: true }
2272
2443
  );
2273
2444
  }
2274
- ps() {
2275
- return this.runner((http) => http.request({ path: "/api/ps", method: "GET" }), {
2276
- singleEndpoint: true
2445
+ ps(request) {
2446
+ return this.runner(
2447
+ (http, signal) => http.request({ path: "/api/ps", method: "GET", signal }),
2448
+ {
2449
+ singleEndpoint: true,
2450
+ ...request?.signal !== void 0 ? { signal: request.signal } : {},
2451
+ ...request?.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
2452
+ }
2453
+ );
2454
+ }
2455
+ /**
2456
+ * Discovers the model's real context window from the server — no more
2457
+ * 2048/4096 guesswork. Consults, in precedence order:
2458
+ *
2459
+ * 1. `GET /api/ps` — the window the **running instance actually
2460
+ * allocated** (exact, reflects the Modelfile `num_ctx` default and
2461
+ * what fit in memory); skipped when the model isn't loaded, when
2462
+ * `skipRunningCheck` is set, or harmlessly on servers without the
2463
+ * field.
2464
+ * 2. `POST /api/show` `parameters` — a Modelfile-authored
2465
+ * `num_ctx <n>` default, i.e. the window a cold load will allocate.
2466
+ * 3. `POST /api/show` `model_info["<arch>.context_length"]` — the
2467
+ * model's **native GGUF maximum** (a ceiling to raise `num_ctx`
2468
+ * toward, not the allocated window).
2469
+ * 4. Fallback — Ollama's conservative unset-`num_ctx` default (2048).
2470
+ *
2471
+ * The result carries every signal it found, so `nativeContextLength:
2472
+ * 131072` vs `contextLength: 4096` (a 32× gap seen in the official API
2473
+ * examples) is visible rather than guessed. Typical wiring:
2474
+ *
2475
+ * ```ts
2476
+ * const { contextLength, source, nativeContextLength } =
2477
+ * await client.models.getContextLength({ model: 'gemma4' });
2478
+ * // -> { contextLength: 4096, source: 'running', nativeContextLength: 131072 }
2479
+ * ```
2480
+ *
2481
+ * Makes two server calls (`/api/ps` + `/api/show`) unless
2482
+ * `skipRunningCheck` is set. A 404 from `/api/ps` (endpoint without the
2483
+ * listing) is treated as "not running" and never fails the lookup;
2484
+ * other transport errors propagate normally.
2485
+ */
2486
+ async getContextLength(request) {
2487
+ let running;
2488
+ if (request.skipRunningCheck !== true) {
2489
+ try {
2490
+ const ps = await this.ps({ signal: request.signal, timeoutMs: request.timeoutMs });
2491
+ running = findRunningModelContextLength(ps.models, request.model);
2492
+ } catch (err) {
2493
+ if (!(err instanceof OllamaNotFoundError)) throw err;
2494
+ }
2495
+ }
2496
+ const showRes = await this.show({
2497
+ model: request.model,
2498
+ signal: request.signal,
2499
+ timeoutMs: request.timeoutMs
2500
+ });
2501
+ return resolveContextLength({
2502
+ running,
2503
+ parameter: extractParameterNumCtx(showRes.parameters),
2504
+ native: extractContextLength(showRes.model_info)
2277
2505
  });
2278
2506
  }
2279
2507
  version() {
@@ -4271,6 +4499,52 @@ var ResponsesModule = class {
4271
4499
  };
4272
4500
 
4273
4501
  // src/conversation.ts
4502
+ function compactConversationHistory(messages, options) {
4503
+ const reserve = Math.max(0, options.reserveForReply ?? 0);
4504
+ const minTail = Math.max(0, options.minTailMessages ?? 2);
4505
+ const budget = Math.max(0, options.maxEstimatedTokens - reserve);
4506
+ let split = 0;
4507
+ while (split < messages.length && messages[split].role === "system") split += 1;
4508
+ const pinned = messages.slice(0, split);
4509
+ const tail = messages.slice(split);
4510
+ let used = 0;
4511
+ for (const message of pinned) used += estimateMessageTokens(message);
4512
+ const retained = [];
4513
+ for (let i = tail.length - 1; i >= 0; i -= 1) {
4514
+ const message = tail[i];
4515
+ const cost = estimateMessageTokens(message);
4516
+ const fits = used + cost <= budget;
4517
+ if (!fits && retained.length >= minTail) break;
4518
+ retained.unshift(message);
4519
+ used += cost;
4520
+ }
4521
+ return [...pinned, ...retained];
4522
+ }
4523
+ function sanitizeHistoryForNextTurn(messages, options = {}) {
4524
+ const keep = Math.max(0, options.keepImagesOnLastMessages ?? 1);
4525
+ const placeholder = options.imagePlaceholder;
4526
+ const cutoff = messages.length - keep;
4527
+ const sanitized = [];
4528
+ for (let index = 0; index < messages.length; index += 1) {
4529
+ const message = messages[index];
4530
+ if (index >= cutoff || !message.images || message.images.length === 0) {
4531
+ sanitized.push(message);
4532
+ continue;
4533
+ }
4534
+ const { images: _evicted, ...rest } = message;
4535
+ if (placeholder === void 0) {
4536
+ sanitized.push(rest);
4537
+ } else {
4538
+ sanitized.push({
4539
+ ...rest,
4540
+ content: rest.content === "" ? placeholder : `${rest.content}
4541
+
4542
+ ${placeholder}`
4543
+ });
4544
+ }
4545
+ }
4546
+ return sanitized;
4547
+ }
4274
4548
  function extractTurnStats(res) {
4275
4549
  const cachedTokens = res.prompt_eval_cached_count ?? 0;
4276
4550
  const evaluatedTokens = res.prompt_eval_count ?? 0;
@@ -4357,6 +4631,48 @@ var ConversationSession = class {
4357
4631
  getMessages() {
4358
4632
  return this.history.map((message) => message);
4359
4633
  }
4634
+ /**
4635
+ * Compacts the session's history in place: pinned system prompt plus the
4636
+ * most recent turns that fit the budget, oldest tailable turns dropped.
4637
+ *
4638
+ * This is the explicit escape hatch for sessions that outgrew their
4639
+ * window — `cacheStats.hitRate` collapsing toward zero while history grows
4640
+ * is the usual symptom, and the pre-flight warnings on `chat()` fire once
4641
+ * the prompt estimate crosses the threshold. After compaction the next
4642
+ * turn's prompt prefix differs from everything before it, so the KV cache
4643
+ * starts cold — visible as a one-turn `evaluatedTokens` spike in
4644
+ * `cacheStats`. Cumulative cache tallies are deliberately **not** reset:
4645
+ * they are observations, and the spike is part of the story.
4646
+ *
4647
+ * Returns the before/after estimates and the drop count. No-op (zero
4648
+ * drops, equal before/after estimates) when the history already fits.
4649
+ */
4650
+ compact(options = {}) {
4651
+ const window = options.maxEstimatedTokens ?? this.config.options?.num_ctx ?? OLLAMA_FALLBACK_CONTEXT_LENGTH;
4652
+ const reserve = options.reserveForReply ?? Math.ceil(window * 0.1);
4653
+ const estimatedTokensBefore = this.history.reduce(
4654
+ (sum, message) => sum + estimateMessageTokens(message),
4655
+ 0
4656
+ );
4657
+ const compacted = compactConversationHistory(this.history, {
4658
+ maxEstimatedTokens: window,
4659
+ reserveForReply: reserve,
4660
+ ...options.minTailMessages !== void 0 ? { minTailMessages: options.minTailMessages } : {}
4661
+ });
4662
+ const estimatedTokensAfter = compacted.reduce(
4663
+ (sum, message) => sum + estimateMessageTokens(message),
4664
+ 0
4665
+ );
4666
+ const droppedMessages = this.history.length - compacted.length;
4667
+ this.history.length = 0;
4668
+ this.history.push(...compacted);
4669
+ return {
4670
+ droppedMessages,
4671
+ estimatedTokensBefore,
4672
+ estimatedTokensAfter,
4673
+ effectiveBudget: window - reserve
4674
+ };
4675
+ }
4360
4676
  /**
4361
4677
  * Clears the conversation back to its initial state (system prompt, zero
4362
4678
  * turns, zeroed cache statistics). The next turn starts with a cold cache
@@ -4375,103 +4691,6 @@ var ConversationSession = class {
4375
4691
  }
4376
4692
  };
4377
4693
 
4378
- // src/context-safety.ts
4379
- var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
4380
- var IMAGE_TOKEN_ESTIMATE = 300;
4381
- var CHARS_PER_TOKEN = 4;
4382
- function isCjkCodePoint(code) {
4383
- return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
4384
- code >= 13312 && code <= 19903 || // CJK Extension A
4385
- code >= 12352 && code <= 12543 || // Hiragana + Katakana
4386
- code >= 44032 && code <= 55215 || // Hangul syllables
4387
- code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
4388
- code >= 65280 && code <= 65519;
4389
- }
4390
- function estimateTokens(text) {
4391
- if (text.length === 0) return 0;
4392
- let cjk = 0;
4393
- let other = 0;
4394
- for (const char of text) {
4395
- if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
4396
- else other += 1;
4397
- }
4398
- return Math.ceil(cjk + other / CHARS_PER_TOKEN);
4399
- }
4400
- var MESSAGE_OVERHEAD_TOKENS = 4;
4401
- function estimateMessageTokens(message) {
4402
- let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
4403
- if (message.thinking !== void 0) total += estimateTokens(message.thinking);
4404
- if (message.tool_calls !== void 0) {
4405
- for (const call of message.tool_calls) {
4406
- total += estimateTokens(call.function.name);
4407
- total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
4408
- }
4409
- }
4410
- if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
4411
- return total;
4412
- }
4413
- function estimateChatRequestTokens(req) {
4414
- let total = 0;
4415
- for (const message of req.messages) total += estimateMessageTokens(message);
4416
- if (req.tools !== void 0) {
4417
- for (const tool of req.tools) {
4418
- total += estimateTokens(tool.function.description ?? "");
4419
- total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
4420
- }
4421
- }
4422
- return total;
4423
- }
4424
- function estimateGenerateRequestTokens(req) {
4425
- let total = estimateTokens(req.prompt ?? "");
4426
- if (req.system !== void 0) total += estimateTokens(req.system);
4427
- if (req.template !== void 0) total += estimateTokens(req.template);
4428
- if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
4429
- return total;
4430
- }
4431
- function checkChatContext(req, options = {}) {
4432
- return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
4433
- }
4434
- function checkGenerateContext(req, options = {}) {
4435
- return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
4436
- }
4437
- function check(estimatedPromptTokens, requestNumCtx, options) {
4438
- const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
4439
- if (requestNumCtx !== void 0) {
4440
- return {
4441
- estimatedPromptTokens,
4442
- effectiveContextLength: requestNumCtx,
4443
- windowSource: "request",
4444
- exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
4445
- };
4446
- }
4447
- if (options.defaultContextLength !== void 0) {
4448
- return {
4449
- estimatedPromptTokens,
4450
- effectiveContextLength: options.defaultContextLength,
4451
- windowSource: "client-default",
4452
- exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
4453
- };
4454
- }
4455
- return {
4456
- estimatedPromptTokens,
4457
- effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
4458
- windowSource: "server-fallback",
4459
- exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
4460
- };
4461
- }
4462
- var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
4463
- function contextWarningMessage(check2, kind) {
4464
- const where = `OllamaClient.${kind}()`;
4465
- const estimate = `~${check2.estimatedPromptTokens} tokens`;
4466
- if (check2.windowSource === "request") {
4467
- return `${where}: estimated prompt size ${estimate} is close to or beyond the request's context window (num_ctx=${check2.effectiveContextLength}). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`options.num_ctx\` or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
4468
- }
4469
- if (check2.windowSource === "client-default") {
4470
- return `${where}: estimated prompt size ${estimate} is close to or beyond the client's \`defaultContextLength\` (${check2.effectiveContextLength}, injected as \`num_ctx\`). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`defaultContextLength\`, set \`options.num_ctx\` per request, or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
4471
- }
4472
- return `${where}: estimated prompt size ${estimate} outright exceeds Ollama's conservative server-default context window (${OLLAMA_FALLBACK_CONTEXT_LENGTH}\u20134096 tokens when \`num_ctx\` is unset \u2014 model-dependent). Unless the model's default is larger, the server will silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Set \`options.num_ctx\` (or the client's \`defaultContextLength\`) to an explicit window to make truncation visible and controllable. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
4473
- }
4474
-
4475
4694
  // src/embed-batch.ts
4476
4695
  var DEFAULT_EMBED_BATCH_SIZE = 32;
4477
4696
  var DEFAULT_EMBED_CONCURRENCY = 3;
@@ -5666,7 +5885,15 @@ var OllamaClient = class {
5666
5885
  createModel = (r) => this.models.create(r);
5667
5886
  deleteModel = (req) => this.models.delete(req);
5668
5887
  copyModel = (req) => this.models.copy(req);
5669
- ps = () => this.models.ps();
5888
+ ps = (request) => this.models.ps(request);
5889
+ /**
5890
+ * Discovers the model's real context window — the allocated window of the
5891
+ * running instance (`/api/ps`), the Modelfile `num_ctx` default, and the
5892
+ * native GGUF maximum (`/api/show` `model_info`), resolved by precedence.
5893
+ * Convenience alias for {@link ModelsClient.getContextLength} — see that
5894
+ * method for the full contract and the precedence order.
5895
+ */
5896
+ getContextLength = (request) => this.models.getContextLength(request);
5670
5897
  version = () => this.models.version();
5671
5898
  createBlob = (digest, data) => this.models.createBlob(digest, data);
5672
5899
  checkBlob = (digest) => this.models.checkBlob(digest);
@@ -6914,11 +7141,29 @@ ${systemPrompt}`
6914
7141
  }
6915
7142
 
6916
7143
  // src/agent/agent.ts
7144
+ var SIGNATURE_REPORT_LIMIT = 200;
7145
+ function stableStringify(value) {
7146
+ if (value === null || typeof value !== "object") {
7147
+ return JSON.stringify(value) ?? "null";
7148
+ }
7149
+ if (Array.isArray(value)) {
7150
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
7151
+ }
7152
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
7153
+ return `{${entries.join(",")}}`;
7154
+ }
7155
+ function canonicalToolCallSignature(toolCall) {
7156
+ return `${toolCall.function.name}(${stableStringify(toolCall.function.arguments)})`;
7157
+ }
7158
+ function reportableSignature(signature) {
7159
+ return signature.length <= SIGNATURE_REPORT_LIMIT ? signature : `${signature.slice(0, SIGNATURE_REPORT_LIMIT)}\u2026`;
7160
+ }
6917
7161
  var Agent = class {
6918
7162
  client;
6919
7163
  tools;
6920
7164
  maxIterations;
6921
7165
  maxToolCalls;
7166
+ maxRepeatedToolCalls;
6922
7167
  hooks;
6923
7168
  validateToolCapability;
6924
7169
  toolContextSize;
@@ -6927,10 +7172,14 @@ var Agent = class {
6927
7172
  this.tools = config.tools;
6928
7173
  this.maxIterations = config.maxIterations ?? 10;
6929
7174
  this.maxToolCalls = config.maxToolCalls;
7175
+ this.maxRepeatedToolCalls = config.maxRepeatedToolCalls;
6930
7176
  this.hooks = config.hooks;
6931
7177
  if (this.maxToolCalls !== void 0 && (!Number.isInteger(this.maxToolCalls) || this.maxToolCalls < 0)) {
6932
7178
  throw new RangeError("Agent maxToolCalls must be a non-negative integer");
6933
7179
  }
7180
+ if (this.maxRepeatedToolCalls !== void 0 && (!Number.isInteger(this.maxRepeatedToolCalls) || this.maxRepeatedToolCalls < 1)) {
7181
+ throw new RangeError("Agent maxRepeatedToolCalls must be a positive integer");
7182
+ }
6934
7183
  this.validateToolCapability = config.validateToolCapability ?? true;
6935
7184
  this.toolContextSize = config.toolContextSize ?? 32768;
6936
7185
  if (!Number.isInteger(this.toolContextSize) || this.toolContextSize <= 0) {
@@ -6977,6 +7226,7 @@ var Agent = class {
6977
7226
  )
6978
7227
  } : input.options;
6979
7228
  let toolCallsExecuted = 0;
7229
+ const signatureCounts = /* @__PURE__ */ new Map();
6980
7230
  for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
6981
7231
  const outcome = await withSpan(
6982
7232
  "ollama.agent.turn",
@@ -7008,6 +7258,24 @@ var Agent = class {
7008
7258
  { maxToolCalls: this.maxToolCalls, toolCallsExecuted }
7009
7259
  );
7010
7260
  }
7261
+ if (this.maxRepeatedToolCalls !== void 0) {
7262
+ for (const tc of toolCalls) {
7263
+ const signature = canonicalToolCallSignature(tc);
7264
+ const executions = (signatureCounts.get(signature) ?? 0) + 1;
7265
+ if (executions > this.maxRepeatedToolCalls) {
7266
+ throw new OllamaAgentToolLoopError(
7267
+ `Agent would repeat tool call "${tc.function.name}" ${executions} times (max ${this.maxRepeatedToolCalls}) without changing arguments \u2014 stuck in a tool loop`,
7268
+ {
7269
+ toolName: tc.function.name,
7270
+ repeatedExecutions: executions,
7271
+ maxRepeatedToolCalls: this.maxRepeatedToolCalls,
7272
+ signature: reportableSignature(signature)
7273
+ }
7274
+ );
7275
+ }
7276
+ signatureCounts.set(signature, executions);
7277
+ }
7278
+ }
7011
7279
  for (const tc of toolCalls) {
7012
7280
  this.hooks?.onToolCallStart?.(tc);
7013
7281
  }
@@ -7040,6 +7308,160 @@ var Agent = class {
7040
7308
  }
7041
7309
  };
7042
7310
 
7311
+ // src/affinity.ts
7312
+ var DEFAULT_CONCURRENT_MODELS = 1;
7313
+ var DEFAULT_PER_MODEL_CONCURRENCY = 1;
7314
+ var DEFAULT_PS_CACHE_TTL_MS = 3e4;
7315
+ var ModelAffinityScheduler = class {
7316
+ constructor(client, options = {}) {
7317
+ this.client = client;
7318
+ this.concurrentModels = options.concurrentModels ?? DEFAULT_CONCURRENT_MODELS;
7319
+ this.perModelConcurrency = options.perModelConcurrency ?? DEFAULT_PER_MODEL_CONCURRENCY;
7320
+ this.psCacheTtlMs = options.psCacheTtlMs ?? DEFAULT_PS_CACHE_TTL_MS;
7321
+ if (!Number.isInteger(this.concurrentModels) || this.concurrentModels < 1) {
7322
+ throw new OllamaClientError("concurrentModels must be an integer >= 1.", {
7323
+ code: "invalid_request"
7324
+ });
7325
+ }
7326
+ if (!Number.isInteger(this.perModelConcurrency) || this.perModelConcurrency < 1) {
7327
+ throw new OllamaClientError("perModelConcurrency must be an integer >= 1.", {
7328
+ code: "invalid_request"
7329
+ });
7330
+ }
7331
+ if (!Number.isFinite(this.psCacheTtlMs) || this.psCacheTtlMs < 0) {
7332
+ throw new OllamaClientError("psCacheTtlMs must be a non-negative number.", {
7333
+ code: "invalid_request"
7334
+ });
7335
+ }
7336
+ }
7337
+ queues = /* @__PURE__ */ new Map();
7338
+ concurrentModels;
7339
+ perModelConcurrency;
7340
+ psCacheTtlMs;
7341
+ psCache;
7342
+ drainWaiters = [];
7343
+ /**
7344
+ * Runs `task` on the scheduler, serialized per model. When `model` is a
7345
+ * list of candidates, the already-loaded one is preferred (fresh
7346
+ * `/api/ps` data subject to the TTL cache); with none loaded — or when the
7347
+ * lookup fails — the first candidate runs. The chosen model is handed to
7348
+ * the task.
7349
+ */
7350
+ async run(model, task) {
7351
+ const candidates = typeof model === "string" ? [model] : [...model];
7352
+ if (candidates.length === 0 || candidates.some((name) => typeof name !== "string" || name === "")) {
7353
+ throw new OllamaClientError("run() requires at least one non-empty model name.", {
7354
+ code: "invalid_request"
7355
+ });
7356
+ }
7357
+ const chosen = candidates.length === 1 ? candidates[0] : await this.chooseCandidate(candidates);
7358
+ return new Promise((resolve, reject) => {
7359
+ const queue = this.queues.get(chosen) ?? { items: [], active: 0 };
7360
+ this.queues.set(chosen, queue);
7361
+ queue.items.push({
7362
+ task,
7363
+ resolve,
7364
+ reject
7365
+ });
7366
+ this.pump();
7367
+ });
7368
+ }
7369
+ /**
7370
+ * Names of the models currently loaded, per `GET /api/ps`, cached for
7371
+ * `psCacheTtlMs`. Degrades to an empty list when the server can't answer
7372
+ * (never throws; candidate selection treats it as "nothing loaded").
7373
+ */
7374
+ async loadedModels() {
7375
+ const names = await this.refreshPsNames();
7376
+ return [...names];
7377
+ }
7378
+ /** Current scheduling state — active models and queued task count. */
7379
+ get stats() {
7380
+ const activeModels = [];
7381
+ let queuedTasks = 0;
7382
+ for (const [name, queue] of this.queues) {
7383
+ if (queue.active > 0) activeModels.push(name);
7384
+ queuedTasks += queue.items.length;
7385
+ }
7386
+ return { activeModels, queuedTasks };
7387
+ }
7388
+ /**
7389
+ * Resolves once every queue is empty and no task is running. Does not
7390
+ * abort anything — tasks are caller-owned; abort them via their own
7391
+ * signals before disposing if needed.
7392
+ */
7393
+ async dispose() {
7394
+ if (this.stats.queuedTasks === 0 && this.stats.activeModels.length === 0) return;
7395
+ await new Promise((resolve) => {
7396
+ this.drainWaiters.push(resolve);
7397
+ });
7398
+ }
7399
+ async chooseCandidate(candidates) {
7400
+ const loaded = await this.refreshPsNames();
7401
+ for (const candidate of candidates) {
7402
+ if (loaded.has(candidate)) return candidate;
7403
+ }
7404
+ return candidates[0];
7405
+ }
7406
+ async refreshPsNames() {
7407
+ const now = Date.now();
7408
+ if (this.psCache !== void 0 && now - this.psCache.at <= this.psCacheTtlMs) {
7409
+ return this.psCache.names;
7410
+ }
7411
+ let names;
7412
+ try {
7413
+ const ps = await this.client.models.ps();
7414
+ names = new Set(ps.models.map((entry) => entry.name));
7415
+ } catch {
7416
+ return /* @__PURE__ */ new Set();
7417
+ }
7418
+ this.psCache = { at: now, names };
7419
+ return names;
7420
+ }
7421
+ pump() {
7422
+ for (const [name, queue] of this.queues) {
7423
+ while (queue.items.length > 0 && queue.active < this.perModelConcurrency) {
7424
+ if (queue.active === 0 && this.activeModelCount() >= this.concurrentModels) {
7425
+ break;
7426
+ }
7427
+ const entry = queue.items.shift();
7428
+ queue.active += 1;
7429
+ this.execute(name, queue, entry);
7430
+ }
7431
+ }
7432
+ }
7433
+ activeModelCount() {
7434
+ let count = 0;
7435
+ for (const queue of this.queues.values()) {
7436
+ if (queue.active > 0) count += 1;
7437
+ }
7438
+ return count;
7439
+ }
7440
+ execute(name, queue, entry) {
7441
+ void (async () => {
7442
+ try {
7443
+ entry.resolve(await entry.task(name));
7444
+ } catch (err) {
7445
+ entry.reject(err);
7446
+ } finally {
7447
+ queue.active -= 1;
7448
+ if (queue.items.length === 0 && queue.active === 0) {
7449
+ this.queues.delete(name);
7450
+ }
7451
+ if (this.stats.queuedTasks === 0 && this.stats.activeModels.length === 0) {
7452
+ const waiters = [...this.drainWaiters];
7453
+ this.drainWaiters.length = 0;
7454
+ for (const resolve of waiters) resolve();
7455
+ }
7456
+ this.pump();
7457
+ }
7458
+ })();
7459
+ }
7460
+ };
7461
+ function createModelAffinityScheduler(client, options) {
7462
+ return new ModelAffinityScheduler(client, options);
7463
+ }
7464
+
7043
7465
  // src/system-one.ts
7044
7466
  var MAX_SYSTEM_ONE_IMAGES_BYTES = 32 * 1024 * 1024;
7045
7467
  var MAX_SYSTEM_ONE_REQUEST_BYTES = 64 * 1024;
@@ -7047,6 +7469,6 @@ function estimateSystemOneRequestBytes(request) {
7047
7469
  return new TextEncoder().encode(JSON.stringify(request)).byteLength;
7048
7470
  }
7049
7471
 
7050
- export { ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_OLLAMA_AGENT_ITERATION, ATTR_OLLAMA_AGENT_MAX_ITERATIONS, ATTR_OLLAMA_ENDPOINT_ATTEMPT, ATTR_OLLAMA_ENDPOINT_NAME, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, Agent, AnthropicCompatClient, AnthropicMessagesStream, ConversationSession, DEFAULT_BACKOFF, DEFAULT_BASE_URL, DEFAULT_CONTEXT_WARNING_THRESHOLD, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_EMBED_CONCURRENCY, DEFAULT_FAILOVER_CODES, DEFAULT_FETCH_BACKOFF_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TIMEOUT_MS, EndpointRegistry, GEN_AI_SYSTEM_OLLAMA, HttpClient, IMAGE_TOKEN_ESTIMATE, KEEP_ALIVE_INDEFINITE, KEEP_ALIVE_UNLOAD, KNOWN_DONE_REASONS, MAX_SYSTEM_ONE_IMAGES_BYTES, MAX_SYSTEM_ONE_REQUEST_BYTES, McpBridge, ModelsClient, NANOS_PER_MS, NANOS_PER_SECOND, NOOP_LOGGER, OLLAMA_CLOUD_BASE_URL, OLLAMA_FALLBACK_CONTEXT_LENGTH, OllamaAbortError, OllamaAgentMaxIterationsError, OllamaAgentMaxToolCallsError, OllamaAuthError, OllamaBadGatewayError, OllamaClient, OllamaClientError, OllamaGenericClientError, OllamaIncompatibleModelError, OllamaMcpError, OllamaModelRoutingError, OllamaNetworkError, OllamaNotFoundError, OllamaOverloadedError, OllamaQuotaExceededError, OllamaRateLimitError, OllamaRequestTooLargeError, OllamaRequestValidationError, OllamaResponseValidationError, OllamaServerError, OllamaServerVersionUnknownError, OllamaSkillInvalidError, OllamaSkillNotFoundError, OllamaStream, OllamaStreamError, OllamaTimeoutError, OllamaToolTimeoutError, OllamaToolValidationError, OllamaUnsupportedCapabilityError, OpenAIChatCompletionStream, OpenAICompatClient, OpenAICompletionStream, OpenAIResponsesStream, OpenAIResponsesStreamError, QuotaManager, RETRYABLE_STATUS_CODES, ResponsesModule, ToolCallAccumulator, ToolRegistry, addSpanEvent, applySkill, batchEmbed, calculateBackoff, checkChatContext, checkEndpointHealth, checkGenerateContext, composeMiddleware, contextWarningMessage, createConsoleLogger, createDecision, createOllamaCloudFreeTierQuota, createTimeoutSignal, defineTool, detectModelCapabilities, embedBatchOverflowMessage, encodeImage, estimateChatRequestTokens, estimateGenerateRequestTokens, estimateSystemOneRequestBytes, estimateTokens, extractUsage, fetchWithBackoff, findOversizedEmbedInputs, imageStringNeedsResolution, inferRuntimeMode, isKeepAliveSugar, isKnownDoneReason, isSameToolCall, listAllMcpTools, listAvailableModels, loadMcpTools, mapError, mergeToolCall, mergeToolCallArgumentsString, mergeToolCallArrays, normalizeChatStream, normalizeGenerateStream, normalizeKeepAlive, normalizeProgressStream, parseFrontmatter, parseNdjsonStream, parseSseStream, parseStructuredOutput, registerMcpTools, resolveCredentialEndpoints, resolveImageInput, setSpanError, toDataStream, toResponse, toTextStream, withRetry, withSpan, zodToJsonSchema };
7472
+ export { ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_OLLAMA_AGENT_ITERATION, ATTR_OLLAMA_AGENT_MAX_ITERATIONS, ATTR_OLLAMA_ENDPOINT_ATTEMPT, ATTR_OLLAMA_ENDPOINT_NAME, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, Agent, AnthropicCompatClient, AnthropicMessagesStream, ConversationSession, DEFAULT_BACKOFF, DEFAULT_BASE_URL, DEFAULT_CONTEXT_WARNING_THRESHOLD, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_EMBED_CONCURRENCY, DEFAULT_FAILOVER_CODES, DEFAULT_FETCH_BACKOFF_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TIMEOUT_MS, EndpointRegistry, GEN_AI_SYSTEM_OLLAMA, HttpClient, IMAGE_TOKEN_ESTIMATE, KEEP_ALIVE_INDEFINITE, KEEP_ALIVE_UNLOAD, KNOWN_DONE_REASONS, MAX_SYSTEM_ONE_IMAGES_BYTES, MAX_SYSTEM_ONE_REQUEST_BYTES, MESSAGE_OVERHEAD_TOKENS, McpBridge, ModelAffinityScheduler, ModelsClient, NANOS_PER_MS, NANOS_PER_SECOND, NOOP_LOGGER, OLLAMA_CLOUD_BASE_URL, OLLAMA_FALLBACK_CONTEXT_LENGTH, OllamaAbortError, OllamaAgentMaxIterationsError, OllamaAgentMaxToolCallsError, OllamaAgentToolLoopError, OllamaAuthError, OllamaBadGatewayError, OllamaClient, OllamaClientError, OllamaGenericClientError, OllamaIncompatibleModelError, OllamaMcpError, OllamaModelRoutingError, OllamaNetworkError, OllamaNotFoundError, OllamaOverloadedError, OllamaQuotaExceededError, OllamaRateLimitError, OllamaRequestTooLargeError, OllamaRequestValidationError, OllamaResponseValidationError, OllamaServerError, OllamaServerVersionUnknownError, OllamaSkillInvalidError, OllamaSkillNotFoundError, OllamaStream, OllamaStreamError, OllamaTimeoutError, OllamaToolTimeoutError, OllamaToolValidationError, OllamaUnsupportedCapabilityError, OpenAIChatCompletionStream, OpenAICompatClient, OpenAICompletionStream, OpenAIResponsesStream, OpenAIResponsesStreamError, QuotaManager, RETRYABLE_STATUS_CODES, ResponsesModule, ToolCallAccumulator, ToolRegistry, addSpanEvent, applySkill, batchEmbed, calculateBackoff, canonicalToolCallSignature, checkChatContext, checkEndpointHealth, checkGenerateContext, compactConversationHistory, composeMiddleware, contextWarningMessage, createConsoleLogger, createDecision, createModelAffinityScheduler, createOllamaCloudFreeTierQuota, createTimeoutSignal, defineTool, detectModelCapabilities, embedBatchOverflowMessage, encodeImage, estimateChatRequestTokens, estimateGenerateRequestTokens, estimateMessageTokens, estimateSystemOneRequestBytes, estimateTokens, extractContextLength as extractNativeContextLength, extractParameterNumCtx, extractUsage, fetchWithBackoff, findOversizedEmbedInputs, findRunningModelContextLength, imageStringNeedsResolution, inferRuntimeMode, isKeepAliveSugar, isKnownDoneReason, isSameToolCall, listAllMcpTools, listAvailableModels, loadMcpTools, mapError, mergeToolCall, mergeToolCallArgumentsString, mergeToolCallArrays, normalizeChatStream, normalizeGenerateStream, normalizeKeepAlive, normalizeProgressStream, parseFrontmatter, parseNdjsonStream, parseSseStream, parseStructuredOutput, registerMcpTools, resolveContextLength, resolveCredentialEndpoints, resolveImageInput, sanitizeHistoryForNextTurn, setSpanError, toDataStream, toResponse, toTextStream, withRetry, withSpan, zodToJsonSchema };
7051
7473
  //# sourceMappingURL=index.js.map
7052
7474
  //# sourceMappingURL=index.js.map