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) {
|
public AgentEntity createAgent(AgentEntity agent) {
|
||||||
agent.setEnabled(true);
|
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) {
|
if (agent.getAgentType() == null) {
|
||||||
agent.setAgentType("react");
|
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>
|
||||||
</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
|
EVIDENCE_INSUFFICIENT banner (info color, not warning). Run completed
|
||||||
fully — the answer text is preserved above; the model just cited
|
fully — the answer text is preserved above; the model just cited
|
||||||
@ -1363,6 +1373,11 @@ const isIncomplete = computed<boolean>(() => {
|
|||||||
return parsedMetadata.value?.finishReason === 'incomplete'
|
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}
|
* True when the graph completed normally but {@code SourceEvidenceLedger}
|
||||||
* found unsupported references. The visible answer is full and persisted;
|
* 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 { createI18n } from 'vue-i18n'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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'
|
import enUS from '@/i18n/locales/en-US'
|
||||||
|
|
||||||
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
||||||
useStreamingMarkdown: (content: unknown) => ({
|
useStreamingMarkdown: (content: () => string) => ({
|
||||||
renderedContent: content,
|
html: computed(content),
|
||||||
copyCode: vi.fn(),
|
copyCode: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
@ -78,6 +78,8 @@ function mountMessage(message: Message, locale = 'zh-CN') {
|
|||||||
chat: {
|
chat: {
|
||||||
incompleteTitle: zhCN.chat.incompleteTitle,
|
incompleteTitle: zhCN.chat.incompleteTitle,
|
||||||
incompleteDescription: zhCN.chat.incompleteDescription,
|
incompleteDescription: zhCN.chat.incompleteDescription,
|
||||||
|
iterationLimitTitle: zhCN.chat.iterationLimitTitle,
|
||||||
|
iterationLimitDescription: zhCN.chat.iterationLimitDescription,
|
||||||
stopped: '已被用户手动中止',
|
stopped: '已被用户手动中止',
|
||||||
interrupted: '已中断并继续处理下一条消息',
|
interrupted: '已中断并继续处理下一条消息',
|
||||||
},
|
},
|
||||||
@ -86,6 +88,8 @@ function mountMessage(message: Message, locale = 'zh-CN') {
|
|||||||
chat: {
|
chat: {
|
||||||
incompleteTitle: enUS.chat.incompleteTitle,
|
incompleteTitle: enUS.chat.incompleteTitle,
|
||||||
incompleteDescription: enUS.chat.incompleteDescription,
|
incompleteDescription: enUS.chat.incompleteDescription,
|
||||||
|
iterationLimitTitle: enUS.chat.iterationLimitTitle,
|
||||||
|
iterationLimitDescription: enUS.chat.iterationLimitDescription,
|
||||||
stopped: 'Stopped manually by user',
|
stopped: 'Stopped manually by user',
|
||||||
interrupted: 'Interrupted and continued with the next message',
|
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()
|
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',
|
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.',
|
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',
|
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)
|
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
|
||||||
evidenceTitle: 'Run completed — some source references could not be verified',
|
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.',
|
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: '回答尚未完成',
|
incompleteTitle: '回答尚未完成',
|
||||||
incompleteDescription: '本次回答未完成,具体原因请查看消息内容:可能是重复输出触发保护、思考达到安全上限,或模型耗尽输出 token 预算。可重新生成、调整思考和输出预算,或拆分任务后继续。',
|
incompleteDescription: '本次回答未完成,具体原因请查看消息内容:可能是重复输出触发保护、思考达到安全上限,或模型耗尽输出 token 预算。可重新生成、调整思考和输出预算,或拆分任务后继续。',
|
||||||
incompleteRetry: '重新生成',
|
incompleteRetry: '重新生成',
|
||||||
|
iterationLimitTitle: '任务尚未完成:已达迭代上限',
|
||||||
|
iterationLimitDescription: '本次运行已达到最大迭代次数,已保留当前结果。可在 Agent 设置中调高「最大迭代次数」,或拆分任务后发送消息继续。',
|
||||||
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
|
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
|
||||||
evidenceTitle: '任务已完成,但部分源码引用未被验证',
|
evidenceTitle: '任务已完成,但部分源码引用未被验证',
|
||||||
evidenceDescription: '回答全文已保留并展示。底部「[证据不足] …」列出的类/文件并未在本次工具结果里被实际读取,模型可能基于命名推断。如需确认这些引用,请追问让模型逐一读取后再下结论。',
|
evidenceDescription: '回答全文已保留并展示。底部「[证据不足] …」列出的类/文件并未在本次工具结果里被实际读取,模型可能基于命名推断。如需确认这些引用,请追问让模型逐一读取后再下结论。',
|
||||||
|
|||||||
@ -364,7 +364,7 @@ async function confirmCreate() {
|
|||||||
systemPrompt: draft.value.systemPrompt,
|
systemPrompt: draft.value.systemPrompt,
|
||||||
tags,
|
tags,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxIterations: 10,
|
maxIterations: 100,
|
||||||
// primaryKbId kept as string to preserve Snowflake precision.
|
// primaryKbId kept as string to preserve Snowflake precision.
|
||||||
primaryKbId: selectedKbId.value,
|
primaryKbId: selectedKbId.value,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1008,7 +1008,7 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
|
|||||||
runtimeConfig: null,
|
runtimeConfig: null,
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
modelName: '', // RFC-03 G1 — empty means "use global default"
|
modelName: '', // RFC-03 G1 — empty means "use global default"
|
||||||
maxIterations: 10,
|
maxIterations: 100,
|
||||||
// Empty so SkillIcon's fallback (🤖) shows instead of pinning a literal
|
// Empty so SkillIcon's fallback (🤖) shows instead of pinning a literal
|
||||||
// emoji into form.icon — otherwise the picker thinks the user picked
|
// emoji into form.icon — otherwise the picker thinks the user picked
|
||||||
// the robot emoji explicitly and reopens on the Emoji tab.
|
// 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