Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nanocoder/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,14 @@
"assets/nanocoder-vscode.vsix"
],
"dependencies": {
"@ai-sdk/anthropic": "^3.0.37",
"@ai-sdk/google": "^3.0.13",
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "2.0.31",
"@anthropic-ai/tokenizer": "^0.0.4",
"@modelcontextprotocol/sdk": "^1.26.0",
"@nanocollective/get-md": "^1.0.2",

"ai": "6.0.116",
"chalk": "^5.2.0",
"cheerio": "^1.1.2",
Expand Down Expand Up @@ -95,6 +96,7 @@
},
"devDependencies": {
"@ava/typescript": "^6.0.0",
"@types/wrap-ansi": "^8.1.0",
"@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3",
"@types/react": "^19.0.0",
Expand Down
6 changes: 6 additions & 0 deletions source/ai-sdk-client/ai-sdk-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,10 @@ export class AISDKClient implements LLMClient {
// No internal state to clear
return Promise.resolve();
}

getTimeout(): number | undefined {
return (
this.providerConfig.socketTimeout ?? this.providerConfig.requestTimeout
);
}
}
109 changes: 101 additions & 8 deletions source/ai-sdk-client/chat/chat-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,32 @@ import {
createPrepareStepHandler,
} from './streaming-handler.js';

/**
* Recursively removes 'description' and 'example' fields from a JSON schema
* to reduce token usage and cognitive load for small models.
*/
// biome-ignore lint/suspicious/noExplicitAny: Necessary for generic JSON schema transformation
export function simplifyToolSchema(schema: any): any {
if (!schema || typeof schema !== 'object') {
return schema;
}

if (Array.isArray(schema)) {
return schema.map(item => simplifyToolSchema(item));
}

// biome-ignore lint/suspicious/noExplicitAny: Final schema object build
const result: any = {};
for (const key of Object.keys(schema)) {
// Skip description and example fields
if (key === 'description' || key === 'example' || key === 'examples') {
continue;
}
result[key] = simplifyToolSchema(schema[key]);
}
return result;
}

export interface ChatHandlerParams {
model: LanguageModel;
currentModel: string;
Expand Down Expand Up @@ -131,6 +157,27 @@ export async function handleChat(
);
}

// Apply schema simplification if enabled
const smmConfig = providerConfig.smallModelMode;
if (smmConfig?.enabled && smmConfig.simplifiedSchemas) {
effectiveTools = Object.fromEntries(
Object.entries(effectiveTools).map(([name, toolDef]) => {
// biome-ignore lint/suspicious/noExplicitAny: Cast to access inputSchema which is an internal AI SDK type often nested deeply
const toolAny = toolDef as any;
return [
name,
{
...toolDef,
inputSchema: simplifyToolSchema(toolAny.inputSchema),
} as AISDKCoreTool,
];
}),
);
logger.debug('Simplified tool schemas for small model mode', {
correlationId,
});
}

// Tools are already in AI SDK format - use directly
const aiTools = shouldDisableTools
? undefined
Expand Down Expand Up @@ -255,17 +302,63 @@ export async function handleChat(

// Check if error indicates tool support issue and we haven't retried
if (!skipTools && isToolSupportError(error)) {
logger.warn('Tool support error detected, retrying without tools', {
model: currentModel,
error: error instanceof Error ? error.message : error,
correlationId,
provider: providerConfig.name,
});
const smmConfig = providerConfig.smallModelMode;

// Case 1: Already in SMM, but hit a tool error.
// Enable aggressive simplification and slim prompt for the retry.
if (smmConfig?.enabled) {
logger.warn(
'Tool support error in Small Model Mode, retrying with maximized simplification',
{
model: currentModel,
correlationId,
},
);

const maxSimpConfig = {
...providerConfig,
smallModelMode: {
...smmConfig,
simplifiedSchemas: true,
slimPrompt: true,
// If minimal profile isn't already active, maybe try it?
// For now just stick to maximizing simplification.
},
};

return await handleChat({
...params,
providerConfig: maxSimpConfig,
skipTools: true, // This currently disables native tools entirely.
// Wait, skipTools=true in handleChat makes it use XML for ALL tools.
// That might actually be better for small models failing native tools.
});
}

// Case 2: Not in SMM. Enable it for the retry.
logger.warn(
'Tool support error detected, retrying with Small Model Mode enabled',
{
model: currentModel,
error: error instanceof Error ? error.message : error,
correlationId,
provider: providerConfig.name,
},
);

const smmRetryConfig = {
...providerConfig,
smallModelMode: {
enabled: true,
simplifiedSchemas: true,
slimPrompt: true,
},
};

// Retry without tools
return await handleChat({
...params,
skipTools: true, // Mark that we're retrying
providerConfig: smmRetryConfig,
skipTools: true, // Use XML fallback for retry
});
}

Expand Down
2 changes: 1 addition & 1 deletion source/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {Box, Text, useApp} from 'ink';
import Spinner from 'ink-spinner';
import React, {useEffect, useMemo} from 'react';
import {shouldRenderWelcome} from '@/app';
import {createStaticComponents} from '@/app/components/app-container';
import {ChatHistory} from '@/app/components/chat-history';
import {ChatInput} from '@/app/components/chat-input';
import {ModalSelectors} from '@/app/components/modal-selectors';
import {shouldRenderWelcome} from '@/app/helpers';
import type {AppProps} from '@/app/types';
import {FileExplorer} from '@/components/file-explorer';
import {IdeSelector} from '@/components/ide-selector';
Expand Down
13 changes: 13 additions & 0 deletions source/app/prompts/main-prompt-slim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
You are Nanocoder, a terminal-based AI coding assistant. Assist the user with software development tasks using tools.

## RULES
- Be concise. Focus on technical accuracy.
- Use `create_task` to break down multi-step work before starting.
- Never use bash tools (`execute_bash`) for exploring files.
- ALWAYS use native tools: `find_files`, `search_file_contents`, `read_file`, `list_directory`.
- Make targeted edits with `string_replace` or `write_file`.
- Chain your tool calls sequentially. Never stop working after one tool call unless the user's task is fully complete.
- Verify your edits.

<!-- DYNAMIC_SYSTEM_INFO_START -->
<!-- DYNAMIC_SYSTEM_INFO_END -->
152 changes: 146 additions & 6 deletions source/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,130 @@ function tryLoadAutoCompactFromPath(
return null;
}

// Try to load small model mode config from a specific path
// Returns the config if found and valid, null otherwise
function tryLoadSmallModelModeFromPath(
configPath: string,
defaults: NonNullable<AppConfig['smallModelMode']>,
): AppConfig['smallModelMode'] | null {
if (!existsSync(configPath)) {
return null;
}

try {
const rawData = readFileSync(configPath, 'utf-8');
const config = JSON.parse(rawData);
const smallModelMode =
config.nanocoder?.smallModelMode || config.smallModelMode;

if (smallModelMode && typeof smallModelMode === 'object') {
return {
enabled:
smallModelMode.enabled !== undefined
? Boolean(smallModelMode.enabled)
: defaults.enabled,
slimPrompt:
smallModelMode.slimPrompt !== undefined
? Boolean(smallModelMode.slimPrompt)
: defaults.slimPrompt,
toolProfile:
smallModelMode.toolProfile !== undefined
? smallModelMode.toolProfile
: defaults.toolProfile,
maxToolsPerTurn:
typeof smallModelMode.maxToolsPerTurn === 'number'
? smallModelMode.maxToolsPerTurn
: defaults.maxToolsPerTurn,
aggressiveCompact:
smallModelMode.aggressiveCompact !== undefined
? Boolean(smallModelMode.aggressiveCompact)
: defaults.aggressiveCompact,
simplifiedSchemas:
smallModelMode.simplifiedSchemas !== undefined
? Boolean(smallModelMode.simplifiedSchemas)
: defaults.simplifiedSchemas,
plannerModel: smallModelMode.plannerModel || defaults.plannerModel,
};
}
} catch (error) {
logError(
`Failed to load small model mode config from ${configPath}: ${String(error)}`,
);
}

return null;
}

// Load small model mode configuration
function loadSmallModelModeConfig(): AppConfig['smallModelMode'] {
const defaults: NonNullable<AppConfig['smallModelMode']> = {
enabled: false,
slimPrompt: true,
toolProfile: 'minimal',
maxToolsPerTurn: 1,
aggressiveCompact: true,
simplifiedSchemas: true,
};

// Try to load from project-level config first
const projectConfigPath = join(process.cwd(), 'agents.config.json');
const projectConfig = tryLoadSmallModelModeFromPath(
projectConfigPath,
defaults,
);
if (projectConfig) {
return projectConfig;
}

// Try global config
const configDir = getConfigPath();
const globalConfigPath = join(configDir, 'agents.config.json');
const globalConfig = tryLoadSmallModelModeFromPath(
globalConfigPath,
defaults,
);
if (globalConfig) {
return globalConfig;
}

// Fallback to home directory
const homePath = join(homedir(), '.agents.config.json');
const homeConfig = tryLoadSmallModelModeFromPath(homePath, defaults);
if (homeConfig) {
return homeConfig;
}

return defaults;
}

/**
* Checks if a model name matches known "small" model patterns (< 10B parameters)
*/
export function isSmallModel(modelName: string): boolean {
const smallModelPatterns = [
/llama-?3\.?[12]?.*?[138]b/i,
/gemma-?2?.*?[279]b/i,
/phi-?[34]/i,
/qwen.*?([0-9.]+)b/i, // Broadly catch qwen small models
/mistral.*?7b/i,
/deepseek.*?coder.*?1\.3b/i,
/stable-?lm-?3b/i,
];

return smallModelPatterns.some(pattern => pattern.test(modelName));
}

// Load auto-compact configuration and Returns default config if not specified
function loadAutoCompactConfig(): AutoCompactConfig {
function loadAutoCompactConfig(
smallModelMode?: AppConfig['smallModelMode'],
): AutoCompactConfig {
const isAggressiveDefault =
smallModelMode?.enabled && smallModelMode?.aggressiveCompact;

const defaults: AutoCompactConfig = {
enabled: true,
threshold: 60,
mode: 'conservative',
threshold: isAggressiveDefault ? 40 : 60,
mode: isAggressiveDefault ? 'aggressive' : 'conservative',
notifyUser: true,
};

Expand Down Expand Up @@ -295,16 +413,38 @@ function loadAppConfig(): AppConfig {
const mcpServersWithSource = loadAllMCPConfigs();
const mcpServers = mcpServersWithSource.map(item => item.server);

// Load auto-compact configuration
const autoCompact = loadAutoCompactConfig();
// Load global small model mode configuration
const smallModelMode = loadSmallModelModeConfig();

// Load auto-compact configuration, passing small model mode to inform defaults
const autoCompact = loadAutoCompactConfig(smallModelMode);

// Load session configuration
const sessions = loadSessionConfig();

// Auto-detect small model mode for providers if not explicitly configured
const augmentedProviders = providers.map(provider => {
if (provider.smallModelMode?.enabled === undefined) {
// Check if the primary model (first one) is a small model
const primaryModel = provider.models[0];
if (primaryModel && isSmallModel(primaryModel)) {
return {
...provider,
smallModelMode: {
...smallModelMode, // Use global defaults
enabled: true,
},
};
}
}
return provider;
});

return {
providers,
providers: augmentedProviders,
mcpServers,
autoCompact,
smallModelMode,
sessions,
};
}
Expand Down
Loading