mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47147978dd |
No files matched your search
@@ -58,8 +58,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Context window',
|
||||
'ai.providers.contextWindow.placeholder': 'e.g. 128000',
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default. Minimum is 1024 tokens.',
|
||||
'ai.providers.contextWindow.error': 'Enter a whole number of at least 1024, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
|
||||
@@ -39,8 +39,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Контекстное окно',
|
||||
'ai.providers.contextWindow.placeholder': 'например, 128000',
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию. Минимум — 1024 токена.',
|
||||
'ai.providers.contextWindow.error': 'Введите целое число не меньше 1024 или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
|
||||
@@ -58,8 +58,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': '上下文窗口',
|
||||
'ai.providers.contextWindow.placeholder': '例如 128000',
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。最小值是 1024 tokens。',
|
||||
'ai.providers.contextWindow.error': '请输入不小于 1024 的正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
|
||||
@@ -58,8 +58,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': '上下文視窗',
|
||||
'ai.providers.contextWindow.placeholder': '例如 128000',
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。最小值是 1024 tokens。',
|
||||
'ai.providers.contextWindow.error': '請輸入不小於 1024 的正整數,或留空。',
|
||||
'ai.providers.refreshModels': '重新整理模型列表',
|
||||
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
||||
'ai.providers.filterModels': '篩選模型...',
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import { sanitizeContextWindow, MIN_CONTEXT_WINDOW_TOKENS } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
@@ -193,7 +193,10 @@ export const ProviderConfigForm: React.FC<{
|
||||
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
|
||||
const rawContextWindow = form.contextWindow.trim();
|
||||
const rawContextWindowNumber = Number(rawContextWindow);
|
||||
if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) {
|
||||
if (
|
||||
rawContextWindow
|
||||
&& (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber < MIN_CONTEXT_WINDOW_TOKENS)
|
||||
) {
|
||||
setContextWindowError(t("ai.providers.contextWindow.error"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
formatMessagesForCompaction,
|
||||
prepareContextCompaction,
|
||||
resolveContextWindow,
|
||||
sanitizeContextWindow,
|
||||
shouldCompactContext,
|
||||
} from "./contextCompaction.ts";
|
||||
|
||||
@@ -314,3 +315,33 @@ test("resolveContextWindow prefers manual override, then fetched model metadata,
|
||||
128000,
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeContextWindow rejects values below the minimum floor", () => {
|
||||
assert.equal(sanitizeContextWindow(1), undefined);
|
||||
assert.equal(sanitizeContextWindow(1023), undefined);
|
||||
assert.equal(sanitizeContextWindow(1024), 1024);
|
||||
assert.equal(sanitizeContextWindow("128000"), 128000);
|
||||
});
|
||||
|
||||
test("resolveContextWindow ignores tiny manual overrides and falls back", () => {
|
||||
assert.equal(
|
||||
resolveContextWindow({
|
||||
provider: {
|
||||
contextWindow: 1,
|
||||
modelContextWindows: { "qwen/test": 131072 },
|
||||
},
|
||||
modelId: "qwen/test",
|
||||
defaultContextWindow: 128000,
|
||||
}),
|
||||
131072,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveContextWindow({
|
||||
provider: { contextWindow: 1 },
|
||||
modelId: "unknown",
|
||||
defaultContextWindow: 128000,
|
||||
}),
|
||||
128000,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import { redactSecretsForModel } from "./harness/modelSecretRedaction";
|
||||
const REDACTED_PAYLOAD_PREVIEW_CHARS = 80;
|
||||
|
||||
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
|
||||
/** Floor for manual/discovered windows — values below this are treated as unset. */
|
||||
export const MIN_CONTEXT_WINDOW_TOKENS = 1_024;
|
||||
export const DEFAULT_PROTECT_RECENT_MESSAGES = 10;
|
||||
|
||||
export const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are summarizing a long Netcatty agent conversation so it can continue without exceeding the model context window.
|
||||
@@ -85,8 +87,8 @@ export function resolveContextWindow({
|
||||
|
||||
export function sanitizeContextWindow(value: unknown): number | undefined {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return undefined;
|
||||
return Math.max(1, Math.round(num));
|
||||
if (!Number.isFinite(num) || num < MIN_CONTEXT_WINDOW_TOKENS) return undefined;
|
||||
return Math.round(num);
|
||||
}
|
||||
|
||||
export function estimateModelMessagesTokens(
|
||||
|
||||
@@ -213,6 +213,51 @@ test('prepareStepContext emits step compaction trace when over budget', async ()
|
||||
assert.ok(events.includes('step'));
|
||||
});
|
||||
|
||||
test('prepareStepContext never returns empty messages after tiny context-window budget prune', async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: 'which processes use the most resources?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
input: { command: "ps -eo pid=,pcpu=,pmem=,rss=,comm= --sort=-pcpu | head -n 11" },
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'terminal_execute',
|
||||
output: {
|
||||
type: 'text',
|
||||
value: 'PID CPU% MEM% RSS COMMAND\n1 0.1 0.2 1024 init\n'.repeat(40),
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const prepared = await prepareStepContext({
|
||||
messages,
|
||||
stepNumber: 1,
|
||||
sessionId: 'chat-tiny-window',
|
||||
chatSessionId: 'chat-tiny-window',
|
||||
contextWindow: 1,
|
||||
maxOutputTokens: 512,
|
||||
runtimeContext: createInitialCattyRuntimeContext({
|
||||
chatSessionId: 'chat-tiny-window',
|
||||
turnId: 'turn-tiny',
|
||||
permissionMode: 'confirm',
|
||||
scopeType: 'terminal',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.ok(prepared.messages.length > 0, 'step budget prune must not empty the prompt');
|
||||
assert.match(JSON.stringify(prepared.messages), /tool-result|processes use the most/);
|
||||
});
|
||||
|
||||
test('prepareStepContext retains handle notice after step budget guard', async () => {
|
||||
const store = new ToolOutputStore();
|
||||
store.store({
|
||||
|
||||
@@ -362,17 +362,24 @@ function applyStepBudgetGuard(
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
if (afterTotal >= threshold && splitAt > 0) {
|
||||
next = keepRecentContextMessages(next, input.protectRecentMessages);
|
||||
didAdjust = true;
|
||||
afterTotal = computeTotalInputTokens({
|
||||
messages: next,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
const recent = keepRecentContextMessages(next, input.protectRecentMessages);
|
||||
// Never accept an empty keep-recent slice — an empty prompt is worse than over-budget.
|
||||
if (recent.length > 0) {
|
||||
next = recent;
|
||||
didAdjust = true;
|
||||
afterTotal = computeTotalInputTokens({
|
||||
messages: next,
|
||||
providerId: input.providerId,
|
||||
reservedTokens: input.reservedTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
while (afterTotal >= threshold && next.length > 2) {
|
||||
const pruned = pruneFirstModelMessage(next);
|
||||
if (pruned.length === next.length) break;
|
||||
// A single head prune can drop a whole user+tool-call+result unit and empty the list
|
||||
// (e.g. contextWindow=1). Reject empty prunes so the follow-up model call still has a prompt.
|
||||
if (pruned.length === 0) break;
|
||||
next = pruned;
|
||||
didAdjust = true;
|
||||
afterTotal = computeTotalInputTokens({
|
||||
@@ -382,6 +389,10 @@ function applyStepBudgetGuard(
|
||||
});
|
||||
}
|
||||
|
||||
if (next.length === 0 && messages.length > 0) {
|
||||
return { messages, didAdjust: false, didTypedCompression: false };
|
||||
}
|
||||
|
||||
return {
|
||||
messages: next,
|
||||
didAdjust: didAdjust || next.length !== messages.length,
|
||||
@@ -515,6 +526,15 @@ export async function prepareStepContext(
|
||||
...(didAdjust ? { lastStepAdjusted: true } : {}),
|
||||
};
|
||||
|
||||
// Last-resort guard: never hand an empty prompt to the model after a tool step.
|
||||
if (working.length === 0 && input.messages.length > 0) {
|
||||
return {
|
||||
messages: integrity.messages.length > 0 ? integrity.messages : input.messages,
|
||||
didAdjust: false,
|
||||
runtimeContext,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: working,
|
||||
didAdjust,
|
||||
|
||||
Reference in new issue
Block a user