fix(agent): raise default iteration budget and explain capped runs (#674)

This commit is contained in:
matevip 2026-11-03 02:27:47 -05:00
parent 29056897d2
commit 082b3b0f88
9 changed files with 180 additions and 5 deletions

View File

@ -146,6 +146,11 @@ public class AgentService {
public AgentEntity createAgent(AgentEntity agent) {
agent.setEnabled(true);
// Match the runtime fallback before insert; older schemas default to 10.
// Explicit budgets (including 0 for no soft cap) remain user-controlled.
if (agent.getMaxIterations() == null) {
agent.setMaxIterations(100);
}
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}

View File

@ -0,0 +1,54 @@
package vip.mate.agent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class AgentServiceIterationBudgetTest {
private final AgentMapper mapper = mock(AgentMapper.class);
private final AgentService service = new AgentService(mapper, null, null, null, null, null, null);
@Test
void omittedBudgetIsStoredAsOneHundredInsteadOfUsingDatabaseDefault() {
AgentEntity agent = new AgentEntity();
agent.setName("Research assistant");
doAnswer(invocation -> {
AgentEntity inserted = invocation.getArgument(0);
assertEquals(100, inserted.getMaxIterations());
return 1;
}).when(mapper).insert(any(AgentEntity.class));
assertEquals(100, service.createAgent(agent).getMaxIterations());
verify(mapper).insert(agent);
}
@ParameterizedTest
@ValueSource(ints = {0, 10, 150})
void explicitCreationBudgetIsPreserved(int budget) {
AgentEntity agent = new AgentEntity();
agent.setName("Custom assistant");
agent.setMaxIterations(budget);
assertEquals(budget, service.createAgent(agent).getMaxIterations());
verify(mapper).insert(agent);
}
@Test
void editingExistingAgentRetainsItsSmallBudget() {
AgentEntity agent = new AgentEntity();
agent.setId(1L);
agent.setName("Existing assistant");
agent.setMaxIterations(10);
agent.setDescription("Updated description");
when(mapper.selectById(1L)).thenReturn(agent);
service.updateAgent(agent);
assertEquals(10, agent.getMaxIterations());
verify(mapper).updateById(agent);
}
}

View File

@ -263,6 +263,16 @@
</div>
</div>
<!-- Use the persisted finish reason so a model's wrap-up cannot hide
an iteration-cap exit. Hide intermediate caps during goal continuation. -->
<div v-if="isIterationLimitReached" class="incomplete-card iteration-limit-card" role="status">
<div class="incomplete-card__header">
<el-icon class="incomplete-card__icon"><WarningFilled /></el-icon>
<span class="incomplete-card__title">{{ $t('chat.iterationLimitTitle') }}</span>
</div>
<p class="incomplete-card__description">{{ $t('chat.iterationLimitDescription') }}</p>
</div>
<!--
EVIDENCE_INSUFFICIENT banner (info color, not warning). Run completed
fully — the answer text is preserved above; the model just cited
@ -1363,6 +1373,11 @@ const isIncomplete = computed<boolean>(() => {
return parsedMetadata.value?.finishReason === 'incomplete'
})
const isIterationLimitReached = computed<boolean>(() =>
props.message.role === 'assistant' && !isGenerating.value
&& parsedMetadata.value?.finishReason === 'max_iterations_reached'
)
/**
* True when the graph completed normally but {@code SourceEvidenceLedger}
* found unsupported references. The visible answer is full and persisted;

View File

@ -1,4 +1,4 @@
import { createApp, defineComponent, h } from 'vue'
import { computed, createApp, defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
@ -7,8 +7,8 @@ import zhCN from '@/i18n/locales/zh-CN'
import enUS from '@/i18n/locales/en-US'
vi.mock('@/composables/useStreamingMarkdown', () => ({
useStreamingMarkdown: (content: unknown) => ({
renderedContent: content,
useStreamingMarkdown: (content: () => string) => ({
html: computed(content),
copyCode: vi.fn(),
}),
}))
@ -78,6 +78,8 @@ function mountMessage(message: Message, locale = 'zh-CN') {
chat: {
incompleteTitle: zhCN.chat.incompleteTitle,
incompleteDescription: zhCN.chat.incompleteDescription,
iterationLimitTitle: zhCN.chat.iterationLimitTitle,
iterationLimitDescription: zhCN.chat.iterationLimitDescription,
stopped: '已被用户手动中止',
interrupted: '已中断并继续处理下一条消息',
},
@ -86,6 +88,8 @@ function mountMessage(message: Message, locale = 'zh-CN') {
chat: {
incompleteTitle: enUS.chat.incompleteTitle,
incompleteDescription: enUS.chat.incompleteDescription,
iterationLimitTitle: enUS.chat.iterationLimitTitle,
iterationLimitDescription: enUS.chat.iterationLimitDescription,
stopped: 'Stopped manually by user',
interrupted: 'Interrupted and continued with the next message',
},
@ -220,3 +224,46 @@ describe('MessageBubble incomplete answer (#660)', () => {
expect(host.querySelector('.incomplete-card__retry')).not.toBeNull()
})
})
describe('MessageBubble iteration limit (#674)', () => {
it.each(['zh-CN', 'en'])('explains the incomplete task and recovery options (%s)', locale => {
const host = mountMessage({ id: 'limit', conversationId: 'conv', role: 'assistant',
content: 'Partial answer', contentParts: [], status: 'completed',
metadata: { finishReason: 'max_iterations_reached' } } as Message, locale)
const card = host.querySelector('.iteration-limit-card')
expect(card).not.toBeNull()
expect(card!.textContent).toContain(locale === 'zh-CN' ? '任务尚未完成' : 'Task is incomplete')
expect(card!.textContent).toContain(locale === 'zh-CN' ? '最大迭代次数' : 'Max Iterations')
expect(host.textContent).toContain('Partial answer')
expect(host.querySelector('.incomplete-card__retry')).toBeNull()
})
it('renders the limit notice for segmented history with serialized metadata', () => {
const host = mountMessage({ id: 'history-limit', conversationId: 'conv', role: 'assistant',
content: '', contentParts: [], status: 'completed', metadata: JSON.stringify({
finishReason: 'max_iterations_reached',
segments: [
{ id: 'tool', type: 'tool_call', toolName: 'search', status: 'completed', seq: 0 },
{ id: 'content', type: 'content', text: 'Saved partial answer', status: 'completed', seq: 1 },
],
}) } as Message)
expect(host.querySelector('.segments-view')).not.toBeNull()
expect(host.querySelector('.iteration-limit-card')).not.toBeNull()
expect(host.textContent).toContain('Saved partial answer')
})
it.each(['normal', 'incomplete', 'evidence_insufficient'])('does not show an iteration warning for %s', finishReason => {
const host = mountMessage({ id: 'other', conversationId: 'conv', role: 'assistant',
content: 'Answer', contentParts: [], status: 'completed', metadata: { finishReason } } as Message)
expect(host.querySelector('.iteration-limit-card')).toBeNull()
})
it('suppresses an intermediate limit notice while the goal is continuing', () => {
const host = mountMessage({ id: 'continuing', conversationId: 'conv', role: 'assistant',
content: 'Working', contentParts: [], status: 'generating',
metadata: { finishReason: 'max_iterations_reached' } } as Message)
expect(host.querySelector('.iteration-limit-card')).toBeNull()
})
})

View File

@ -702,6 +702,8 @@ export default {
incompleteTitle: 'Answer is incomplete',
incompleteDescription: 'This answer did not finish. Check the message for the reason: repeated output, a reasoning safety limit, or an exhausted output token budget. Regenerate, adjust thinking and output budgets, or split the task to continue.',
incompleteRetry: 'Regenerate',
iterationLimitTitle: 'Task is incomplete: iteration limit reached',
iterationLimitDescription: 'This run reached its iteration limit. Results so far are preserved. Increase “Max Iterations” in the agent settings, or split the task and send a follow-up to continue.',
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
evidenceTitle: 'Run completed — some source references could not be verified',
evidenceDescription: 'The full answer is preserved above. The classes/files listed in the trailing "[evidence insufficient] …" line were not actually opened during this run; the model may have inferred them from naming. Ask a follow-up to have each one read before relying on those references.',

View File

@ -702,6 +702,8 @@ export default {
incompleteTitle: '回答尚未完成',
incompleteDescription: '本次回答未完成,具体原因请查看消息内容:可能是重复输出触发保护、思考达到安全上限,或模型耗尽输出 token 预算。可重新生成、调整思考和输出预算,或拆分任务后继续。',
incompleteRetry: '重新生成',
iterationLimitTitle: '任务尚未完成:已达迭代上限',
iterationLimitDescription: '本次运行已达到最大迭代次数,已保留当前结果。可在 Agent 设置中调高「最大迭代次数」,或拆分任务后发送消息继续。',
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
evidenceTitle: '任务已完成,但部分源码引用未被验证',
evidenceDescription: '回答全文已保留并展示。底部「[证据不足] …」列出的类/文件并未在本次工具结果里被实际读取,模型可能基于命名推断。如需确认这些引用,请追问让模型逐一读取后再下结论。',

View File

@ -364,7 +364,7 @@ async function confirmCreate() {
systemPrompt: draft.value.systemPrompt,
tags,
enabled: true,
maxIterations: 10,
maxIterations: 100,
// primaryKbId kept as string to preserve Snowflake precision.
primaryKbId: selectedKbId.value,
}

View File

@ -1008,7 +1008,7 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
runtimeConfig: null,
systemPrompt: '',
modelName: '', // RFC-03 G1 — empty means "use global default"
maxIterations: 10,
maxIterations: 100,
// Empty so SkillIcon's fallback (🤖) shows instead of pinning a literal
// emoji into form.icon — otherwise the picker thinks the user picked
// the robot emoji explicitly and reopens on the Emoji tab.

View File

@ -0,0 +1,50 @@
import { createApp, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, expect, it, vi } from 'vitest'
import zhCN from '@/i18n/locales/zh-CN'
import { agentApi, agentBindingApi } from '@/api'
import AgentCreateWizard from '../AgentCreateWizard.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/api', () => ({
agentApi: { generate: vi.fn(), create: vi.fn() },
agentBindingApi: { setSkills: vi.fn(), setTools: vi.fn() },
toolApi: { listAvailable: vi.fn().mockResolvedValue({ data: [] }) },
skillApi: { page: vi.fn().mockResolvedValue({ data: [] }) },
wikiApi: { listBindableKBs: vi.fn().mockResolvedValue({ data: [] }) },
}))
vi.mock('@/composables/useMcToast', () => ({ mcToast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('@/components/common/SkillIcon.vue', () => ({ default: defineComponent({ template: '<span />' }) }))
vi.mock('@/components/common/SkillIconPicker.vue', () => ({ default: defineComponent({ template: '<span />' }) }))
vi.mock('@/components/agent/WizardCapabilityPicker.vue', () => ({ default: defineComponent({ template: '<span />' }) }))
let app: ReturnType<typeof createApp> | undefined
afterEach(() => {
app?.unmount()
document.body.innerHTML = ''
vi.clearAllMocks()
})
it('creates the generated agent with a 100-round budget so complex tasks can exceed ten rounds', async () => {
vi.mocked(agentApi.generate).mockResolvedValue({ data: { name: 'Research assistant' } } as never)
vi.mocked(agentApi.create).mockResolvedValue({ data: { id: '123', name: 'Research assistant' } } as never)
vi.mocked(agentBindingApi.setSkills).mockResolvedValue({} as never)
vi.mocked(agentBindingApi.setTools).mockResolvedValue({} as never)
const host = document.createElement('div')
document.body.appendChild(host)
app = createApp(AgentCreateWizard)
app.use(createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zhCN } }))
app.mount(host)
const input = host.querySelector('textarea')!
input.value = 'Research a complex task'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
host.querySelector<HTMLButtonElement>('.wiz-btn-primary')!.click()
await vi.waitFor(() => expect(host.querySelector('.wiz-notice')).not.toBeNull())
host.querySelector<HTMLButtonElement>('.wiz-actions .wiz-btn-primary')!.click()
await vi.waitFor(() => expect(agentApi.create).toHaveBeenCalledWith(expect.objectContaining({
name: 'Research assistant', maxIterations: 100,
})))
})