mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-04 06:55:32 +08:00
fix(agent): raise default iteration budget and explain capped runs (#674)
This commit is contained in:
parent
29056897d2
commit
082b3b0f88
@ -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");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -702,6 +702,8 @@ export default {
|
||||
incompleteTitle: '回答尚未完成',
|
||||
incompleteDescription: '本次回答未完成,具体原因请查看消息内容:可能是重复输出触发保护、思考达到安全上限,或模型耗尽输出 token 预算。可重新生成、调整思考和输出预算,或拆分任务后继续。',
|
||||
incompleteRetry: '重新生成',
|
||||
iterationLimitTitle: '任务尚未完成:已达迭代上限',
|
||||
iterationLimitDescription: '本次运行已达到最大迭代次数,已保留当前结果。可在 Agent 设置中调高「最大迭代次数」,或拆分任务后发送消息继续。',
|
||||
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
|
||||
evidenceTitle: '任务已完成,但部分源码引用未被验证',
|
||||
evidenceDescription: '回答全文已保留并展示。底部「[证据不足] …」列出的类/文件并未在本次工具结果里被实际读取,模型可能基于命名推断。如需确认这些引用,请追问让模型逐一读取后再下结论。',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
})))
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user