feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999)
* feat: round-limit handling — Continue affordance at the cap + configurable cap
When the agent loop runs out of rounds (per-message step cap, default 20)
while still actively using tools, it stopped silently mid-task. Now:
1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows
a "Continue" pill at the bottom of the chat that resumes the task from where
it left off. Repeated cap-hits each get a fresh Continue (multiple continues
in a row).
2. The cap is configurable in Settings → Agent ("Max steps per message"),
validated on the client, at the save endpoint, and at the read site.
- src/agent_loop.py: track `_exhausted_rounds` (set only when a full
tool-executing round completes on the last allowed round — i.e. the agent
wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged).
- routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as
`max_rounds`; forward the new event through the SSE relay.
- routes/auth_routes.py: validate numeric settings on save (int + clamp;
agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int).
- src/settings.py: default `agent_max_rounds = 20`.
- static/: Settings input + client-side clamp; the Continue pill (reuses the
existing .stopped-indicator / .continue-btn classes and theme vars
--border/--fg/--bg/--accent); appended to the chat container so it survives
the message re-render at stream finalize. chat.js cache version bumped.
* test: cover rounds_exhausted emission (cap-hit vs normal finish)
Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings:
a tool block every round exhausts the cap and must emit rounds_exhausted; a
plain answer hits the done-break and must not. Guards the for/else logic.
This commit is contained in:
committed by
GitHub
parent
a54f41037d
commit
64d65b73c1
@@ -1836,6 +1836,44 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (json.type === 'rounds_exhausted') {
|
||||
// The agent hit the per-turn step limit while still working.
|
||||
// Offer a Continue button instead of stalling silently.
|
||||
// NOTE: append to the chat-history container (bottom), NOT the
|
||||
// message body — the body innerHTML is re-rendered at stream
|
||||
// finalize, which would wipe a note placed inside it.
|
||||
const _chatBox = document.getElementById('chat-history');
|
||||
if (!_isBg && _chatBox) {
|
||||
// Drop any prior box so repeated cap-hits each get a fresh
|
||||
// Continue at the bottom (multiple continues in a row).
|
||||
const _old = _chatBox.querySelector('.rounds-exhausted');
|
||||
if (_old) _old.remove();
|
||||
const note = document.createElement('div');
|
||||
note.className = 'stopped-indicator rounds-exhausted';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'rounds-exhausted-label';
|
||||
label.textContent = `Reached the ${json.rounds || ''}-step limit — not finished.`;
|
||||
note.appendChild(label);
|
||||
const contBtn = document.createElement('button');
|
||||
contBtn.className = 'continue-btn';
|
||||
contBtn.title = 'Continue the task';
|
||||
contBtn.textContent = 'Continue ▸';
|
||||
const _holder = currentHolder;
|
||||
contBtn.addEventListener('click', () => {
|
||||
note.remove();
|
||||
_hideUserBubble = true;
|
||||
_pendingContinue = _holder;
|
||||
const msgInput = uiModule.el('message');
|
||||
if (msgInput) {
|
||||
msgInput.value = 'You hit the step limit before finishing — the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.';
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (sb) sb.click();
|
||||
}
|
||||
});
|
||||
note.appendChild(contBtn);
|
||||
_chatBox.appendChild(note);
|
||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||
}
|
||||
} else if (json.type === 'attachments') {
|
||||
if (_isBg) continue;
|
||||
// Update user bubble — replace file chips with image previews
|
||||
|
||||
@@ -1558,6 +1558,7 @@ async function initResearchSearchSettings() {
|
||||
/* ── Agent Settings (AI tab) ── */
|
||||
async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
@@ -1565,23 +1566,41 @@ async function initAgentSettings() {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var val = parseInt(toolsInput.value, 10) || 0;
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent_max_tool_calls: val })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = val > 0 ? 'Limit: ' + val + ' tool calls per message' : 'Unlimited';
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
msg.textContent = cur > 0 ? 'Limit: ' + cur + ' tool calls per message' : 'Unlimited';
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '');
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user