Skip to content

Commit eb691a1

Browse files
author
Brendan Gray
committed
v1.7.20: 8-fix batch — write_file reliability, context overflow guard, duplicate write prevention, code-dump nudge, adaptive tool count
Fix 1: First-turn prompt overflow guard (iteration 1 context check) Fix 2: Conditional post-write feedback (skip nudge for already-written files) Fix 3: STUCK_THRESHOLD 3->2, cross-turn dedup always active Fix 4: Few-shot tool-call example injection for small models Fix 5: Method 3a.5 string-arg function-call recognition (append_to_file) Fix 6: formalCallCount tracking + code-dump nudge mechanism Fix 7: Adaptive minimal tool set for contexts <4096 tokens Fix 8: Per-pass context budget check for seamless continuation
1 parent df06f21 commit eb691a1

3 files changed

Lines changed: 137 additions & 16 deletions

File tree

main/agenticChat.js

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function register(ctx) {
108108
ipcMain.handle('ai-chat', async (_, message, context) => {
109109
const mainWindow = ctx.getMainWindow();
110110
const MAX_AGENTIC_ITERATIONS = context?.maxIterations || _readConfig()?.userSettings?.maxAgenticIterations || 100; // Default 100; overridable via Settings UI
111-
const STUCK_THRESHOLD = 3; // Same tool+params repeated this many times = stuck
111+
const STUCK_THRESHOLD = 2; // Same tool+params repeated this many times = stuck
112112
const CYCLE_MIN_REPEATS = 3; // A 2-4 tool cycle must repeat this many times to be flagged
113113
let _completenessCheckedFiles = null; // One-shot guard for post-write completeness checks
114114

@@ -774,8 +774,21 @@ function register(ctx) {
774774
const useCompactTools = toolPromptStyle === 'grammar-only' || toolPromptStyle === 'compact';
775775
if (useCompactTools) {
776776
// Grammar handles structural validity — just tell the model it has tools
777-
const compactHint = mcpToolServer.getCompactToolHint(effectiveTaskType);
777+
// For extremely constrained contexts (<4096 tokens), use a minimal tool set
778+
// to save ~400 tokens. Better to have fewer tools than to overflow the context.
779+
const compactHint = totalCtx < 4096
780+
? mcpToolServer.getCompactToolHint(effectiveTaskType, { minimal: true })
781+
: mcpToolServer.getCompactToolHint(effectiveTaskType);
778782
appendIfBudget(compactHint + '\n', 'tools-compact');
783+
784+
// Inject few-shot tool call examples if the profile requests them.
785+
// These give small models a concrete template of the JSON format,
786+
// dramatically improving formal tool call rate vs. code-dump-to-fallback.
787+
const _fewShotCount = modelProfile.generation?.fewShotExamples ?? 0;
788+
if (_fewShotCount > 0 && tokenBudget > 150) {
789+
const _fewShotExample = '### Tool Call Example\nUser: Create an HTML page called hello.html with a greeting\nAssistant:\n```json\n{"tool":"write_file","params":{"filePath":"hello.html","content":"<!DOCTYPE html>\\n<html><head><title>Hello</title></head><body><h1>Hello!</h1></body></html>"}}\n```\n';
790+
appendIfBudget(_fewShotExample, 'few-shot-example');
791+
}
779792
} else {
780793
// Full tool prompt for large models, text-mode fallback, and first-time context
781794
const toolPrompt = mcpToolServer.getToolPromptForTask(effectiveTaskType);
@@ -1143,6 +1156,40 @@ function register(ctx) {
11431156

11441157
console.log(`[AI Chat] Prompt: ~${estimateTokens(typeof currentPrompt === 'string' ? currentPrompt : (currentPrompt.systemContext || '') + (currentPrompt.userMessage || ''))} tokens`);
11451158

1159+
// ── FIRST-TURN PROMPT OVERFLOW GUARD ──
1160+
// On the first iteration, check if the assembled prompt exceeds or nearly
1161+
// fills the context window. progressiveContextCompaction can't help here
1162+
// (nothing to compact yet). Instead, rebuild the prompt with less content.
1163+
if (iteration === 1) {
1164+
const _ftPromptText = typeof currentPrompt === 'string' ? currentPrompt : ((currentPrompt.systemContext || '') + (currentPrompt.userMessage || ''));
1165+
const _ftPromptTokens = estimateTokens(_ftPromptText);
1166+
const _ftHeadroom = totalCtx - _ftPromptTokens;
1167+
if (_ftHeadroom < Math.floor(totalCtx * 0.15)) {
1168+
console.log(`[AI Chat] First-turn overflow guard: prompt ~${_ftPromptTokens} tokens, ctx=${totalCtx}, headroom=${_ftHeadroom}`);
1169+
// Step 1: Rebuild with minimal dynamic context (10% budget)
1170+
currentPrompt = {
1171+
systemContext: buildStaticPrompt(),
1172+
userMessage: buildDynamicContext(undefined, Math.floor(maxPromptTokens * 0.10)) + message
1173+
};
1174+
let _ftRetryText = (currentPrompt.systemContext || '') + (currentPrompt.userMessage || '');
1175+
let _ftRetryTokens = estimateTokens(_ftRetryText);
1176+
if (totalCtx - _ftRetryTokens < Math.floor(totalCtx * 0.15)) {
1177+
// Step 2: Rebuild with NO dynamic context at all
1178+
currentPrompt = {
1179+
systemContext: buildStaticPrompt(),
1180+
userMessage: message
1181+
};
1182+
_ftRetryText = (currentPrompt.systemContext || '') + (currentPrompt.userMessage || '');
1183+
_ftRetryTokens = estimateTokens(_ftRetryText);
1184+
}
1185+
if (totalCtx - _ftRetryTokens < 128) {
1186+
// Still too tight — cap effectiveMaxTokens to whatever room remains
1187+
console.log(`[AI Chat] Context extremely constrained: reducing response budget`);
1188+
}
1189+
console.log(`[AI Chat] First-turn overflow resolved: prompt ~${_ftRetryTokens} tokens (was ~${_ftPromptTokens})`);
1190+
}
1191+
}
1192+
11461193
// ── PROACTIVE PRE-GENERATION CONTEXT CHECK ──
11471194
// Before generating, estimate context usage. If it's already high (>60%),
11481195
// proactively compact BEFORE the generation call instead of waiting for
@@ -1782,10 +1829,29 @@ function register(ctx) {
17821829
&& !_timedOut
17831830
&& !isStale();
17841831
if (_wasTruncated && continuationCount < 50) {
1785-
continuationCount++;
1786-
const _truncReason = _hasUnclosedToolFence ? 'unclosed tool fence (EOS mid-block)' : 'maxTokens';
1787-
console.log(`[AI Chat] Seamless continuation ${continuationCount}/50 — ${_truncReason}, continuing in same bubble`);
1788-
iteration--; // Continuation is not a new agentic step
1832+
// ── Per-pass context budget check ──
1833+
// Before continuing, verify context isn't already too full.
1834+
// Without this, seamless continuation can fill the entire context
1835+
// across many passes, causing stalls and repetition.
1836+
let _contContextPct = 0;
1837+
try {
1838+
let _contUsed = 0;
1839+
try { if (llmEngine.sequence?.nTokens) _contUsed = llmEngine.sequence.nTokens; } catch (_) {}
1840+
if (!_contUsed) {
1841+
const _contPromptLen = typeof currentPrompt === 'string' ? currentPrompt.length : ((currentPrompt.systemContext || '').length + (currentPrompt.userMessage || '').length);
1842+
_contUsed = Math.ceil((_contPromptLen + fullResponseText.length) / 4);
1843+
}
1844+
_contContextPct = _contUsed / totalCtx;
1845+
} catch (_) {}
1846+
if (_contContextPct > 0.70) {
1847+
console.log(`[AI Chat] Seamless continuation aborted: context at ${Math.round(_contContextPct * 100)}% (>70% budget). Rotating instead.`);
1848+
continuationCount = 0;
1849+
// Fall through to normal post-generation compaction / rotation below
1850+
} else {
1851+
continuationCount++;
1852+
const _truncReason = _hasUnclosedToolFence ? 'unclosed tool fence (EOS mid-block)' : 'maxTokens';
1853+
console.log(`[AI Chat] Seamless continuation ${continuationCount}/50 — ${_truncReason}, continuing in same bubble`);
1854+
iteration--; // Continuation is not a new agentic step
17891855
let _continuationUserMsg;
17901856
if (_hasUnclosedToolFence) {
17911857
// EOS fired mid-JSON: tell the model exactly where the output was cut.
@@ -1815,6 +1881,7 @@ function register(ctx) {
18151881
userMessage: _continuationUserMsg,
18161882
};
18171883
continue;
1884+
} // close else block from context budget check
18181885
}
18191886
// Natural stop or max continuations reached — reset counter for next response
18201887
if (!_wasTruncated) continuationCount = 0;
@@ -2009,9 +2076,10 @@ function register(ctx) {
20092076
toolResults = await mcpToolServer.processResponse(_stitchedForMcp, textOpts);
20102077
}
20112078

2012-
// Duplicate call detection — disabled by default.
2013-
// Enable via Settings → enableLoopDetection: true.
2014-
if ((_readConfig()?.userSettings?.enableLoopDetection ?? false) && toolResults.hasToolCalls && toolResults.results.length > 0) {
2079+
// Cross-turn duplicate call detection — always active.
2080+
// Blocks exact-same tool+params calls within a single iteration to prevent
2081+
// fallback-driven duplicate writes.
2082+
if (toolResults.hasToolCalls && toolResults.results.length > 0) {
20152083
const iterationCallSigs = new Set();
20162084
const dedupedResults = [];
20172085
for (const tr of toolResults.results) {
@@ -2071,7 +2139,20 @@ function register(ctx) {
20712139

20722140
}
20732141

2074-
// No more tool calls - we're done
2142+
// No more tool calls - check for code-dump nudge opportunity.
2143+
// If the model produced a long response with code blocks but no formal tool calls,
2144+
// and nudges remain, give it one chance to re-try with proper tool format.
2145+
const _hasCodeBlocks = /```(?:html?|css|javascript|js|typescript|ts|python|py|json)\s*\n[\s\S]{50,}```/i.test(responseText);
2146+
if (_hasCodeBlocks && nudgesRemaining > 0 && iteration < effectiveMaxIterations - 1) {
2147+
nudgesRemaining--;
2148+
console.log(`[AI Chat] Code-dump nudge: model produced code blocks without tool calls. Nudges remaining: ${nudgesRemaining}`);
2149+
const _nudgeMsg = `[SYSTEM: You wrote code directly in the chat instead of saving it to a file. Use the write_file tool to save code to a file. Format: \`\`\`json\n{"tool":"write_file","params":{"filePath":"filename.ext","content":"...your code..."}}\n\`\`\`\nDo NOT repeat the code in chat — call write_file now.]`;
2150+
currentPrompt = {
2151+
systemContext: currentPrompt.systemContext,
2152+
userMessage: _nudgeMsg,
2153+
};
2154+
continue; // retry without breaking
2155+
}
20752156
console.log(`[AI Chat] No more tool calls, ending agentic loop`);
20762157
break;
20772158
}
@@ -2296,9 +2377,19 @@ function register(ctx) {
22962377
} else if ((tr.tool === 'write_file' || tr.tool === 'append_to_file') && tr.result?.path) {
22972378
const byteCount = (tr.params?.content || '').length;
22982379
toolFeedback += `**File written:** \`${tr.result.path}\` (${byteCount.toLocaleString()} chars, ${tr.result.isNew ? 'new file' : 'updated'})\n`;
2299-
// Continuation hint — signal to keep building multi-section or multi-file output
2380+
// Continuation hint — context-aware to prevent duplicate-write loops.
2381+
// If the same filePath was already written in a previous iteration, do NOT
2382+
// nudge to "call write_file IMMEDIATELY" — that causes the model to re-write
2383+
// the same file repeatedly. Instead, signal task completion for that file.
23002384
if (tr.tool === 'write_file') {
2301-
toolFeedback += `*File written. If the task requires MORE FILES to be created, call write_file IMMEDIATELY for the next file. If this file needs more content added, call append_to_file IMMEDIATELY. Do NOT stop until ALL required files and content are fully written.*\n`;
2385+
const _prevWritesSameFile = allToolResults.slice(0, currentIterationStart).some(
2386+
prev => prev.tool === 'write_file' && prev.params?.filePath === tr.params?.filePath
2387+
);
2388+
if (_prevWritesSameFile) {
2389+
toolFeedback += `*File updated (already created earlier). This file is complete. If the original task required OTHER files, create them now. Otherwise, provide a summary of what was built.*\n`;
2390+
} else {
2391+
toolFeedback += `*File written. If the task requires MORE FILES to be created, call write_file IMMEDIATELY for the next file. If this file needs more content added, call append_to_file IMMEDIATELY. Do NOT stop until ALL required files and content are fully written.*\n`;
2392+
}
23022393
} else {
23032394
toolFeedback += `*Content appended. If more content remains for this file, call append_to_file again immediately. If other files still need to be created, call write_file for the next required file. Stop only when ALL required files and sections are fully written.*\n`;
23042395
}

main/mcpToolServer.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2216,8 +2216,9 @@ class MCPToolServer {
22162216
* Grammar handles structural validity; this provides semantic guidance.
22172217
* Task-filtered to reduce noise. ~350 tokens.
22182218
*/
2219-
getCompactToolHint(taskType) {
2219+
getCompactToolHint(taskType, options) {
22202220
if (taskType === 'chat') return '';
2221+
const minimal = options && options.minimal;
22212222
let hint = '## Your Tools\n';
22222223
hint += 'Call tools with: ```json\n{"tool":"read_file","params":{"filePath":"index.js"}}\n```\nUse the actual parameter names shown below for each tool.\n';
22232224
if (this.projectPath) {
@@ -2235,6 +2236,16 @@ class MCPToolServer {
22352236
hint += '- **run_command**(command) — Run a terminal/shell command.\n';
22362237
hint += '\n';
22372238

2239+
// For minimal mode (extremely constrained contexts <4096 tokens), stop here.
2240+
// The 6 file tools above are the essential set. Skip browser, memory, planning
2241+
// to save ~400 tokens of context budget.
2242+
if (minimal) {
2243+
hint += '### Web\n';
2244+
hint += '- **web_search**(query) — Search the internet.\n\n';
2245+
hint += 'ALWAYS use write_file for code — NEVER output code as chat text.\n';
2246+
return hint;
2247+
}
2248+
22382249
if (taskType === 'code') {
22392250
hint += '### Code Task Examples\n';
22402251
hint += '```json\n{"tool":"write_file","params":{"filePath":"game.html","content":"<!DOCTYPE html>\\n<html>...</html>"}}\n```\n';

main/tools/mcpToolParser.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,25 @@ function parseToolCalls(text) {
492492
} catch (_) {}
493493
}
494494

495+
// 3a.5: Function-call syntax with string arguments (not JSON):
496+
// e.g. append_to_file('site.html', '<script src="..."></script>')
497+
// or write_file('index.html', '<!DOCTYPE html>...')
498+
// Small models sometimes produce Python/JS-style calls instead of JSON params.
499+
if (toolCalls.length === 0) {
500+
const FILE_TOOL_NAMES = ['write_file', 'append_to_file', 'create_file'];
501+
const strArgRegex = new RegExp(`\\b(${FILE_TOOL_NAMES.join('|')})\\s*\\(\\s*['"]([^'"]+)['"]\\s*,\\s*['"]([\\s\\S]*?)['"]\\s*\\)`, 'g');
502+
let strArgMatch;
503+
while ((strArgMatch = strArgRegex.exec(cleanedText)) !== null) {
504+
const toolName = TOOL_NAME_ALIASES[strArgMatch[1].toLowerCase()] || strArgMatch[1];
505+
const filePath = strArgMatch[2];
506+
const content = strArgMatch[3];
507+
if (filePath && content && content.length > 5) {
508+
console.log('[MCP] Method 3a.5: Found string-arg function-call syntax:', toolName);
509+
toolCalls.push({ tool: toolName, params: { filePath, content } });
510+
}
511+
}
512+
}
513+
495514
// 3b: Plain JSON with filePath+content but no "tool" key → infer write_file
496515
if (toolCalls.length === 0) {
497516
const plainJsonRegex = /\{\s*"filePath"\s*:\s*"[^"]+"\s*,\s*"content"\s*:/g;
@@ -922,10 +941,10 @@ async function processResponse(responseText, options = {}) {
922941
const result = await this.executeTool(call.tool, call.params || {});
923942
results.push({ tool: call.tool, params: call.params, result });
924943
}
925-
return { hasToolCalls: true, results, capped: fbCapped, skippedToolCalls: fbSkipped };
944+
return { hasToolCalls: true, results, capped: fbCapped, skippedToolCalls: fbSkipped, formalCallCount: 0 };
926945
}
927946
console.log('[MCP] No fallback tool calls either');
928-
return { hasToolCalls: false, results: [] };
947+
return { hasToolCalls: false, results: [], formalCallCount: 0 };
929948
}
930949

931950
// ── Browser Tool Capping ──
@@ -1011,7 +1030,7 @@ async function processResponse(responseText, options = {}) {
10111030
console.log(`[MCP] Browser cap enforced: executed ${browserStateChanges} state-changing actions, skipped ${browserSkipped}`);
10121031
}
10131032

1014-
return { hasToolCalls: true, results, capped: capped || browserCapped, skippedToolCalls: skippedCount + browserSkipped };
1033+
return { hasToolCalls: true, results, capped: capped || browserCapped, skippedToolCalls: skippedCount + browserSkipped, formalCallCount: toolCalls.length };
10151034
}
10161035

10171036
/**

0 commit comments

Comments
 (0)