Skip to content

Commit 584fabb

Browse files
committed
✨ 工具调用防护 startIndex 避免重复警告、Task/SubAgent UI 优化与连接测试改进
1 parent 34ce3cc commit 584fabb

7 files changed

Lines changed: 326 additions & 54 deletions

File tree

src/app/service/agent/tool_call_guard.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,48 @@ describe("detectToolCallIssues", () => {
179179
});
180180
});
181181

182+
describe("startIndex 防止重复警告", () => {
183+
it("使用 startIndex 跳过已警告过的记录后不再重复触发", () => {
184+
const history: ToolCallRecord[] = [
185+
{ name: "get_tab_content", args: '{"tab_id":123,"prompt":"a"}', result: "...", iteration: 1 },
186+
{ name: "get_tab_content", args: '{"tab_id":123,"prompt":"b"}', result: "...", iteration: 2 },
187+
{ name: "get_tab_content", args: '{"tab_id":123,"prompt":"c"}', result: "...", iteration: 3 },
188+
];
189+
// 第一次检测:触发警告
190+
const warning1 = detectToolCallIssues(history);
191+
expect(warning1).not.toBeNull();
192+
expect(warning1).toContain("get_tab_content");
193+
194+
// 模拟警告后推进 startIndex
195+
const startIndex = history.length;
196+
197+
// 后续添加不同工具调用
198+
history.push({ name: "execute_script", args: '{"code":"click()"}', result: '{"result":"ok"}', iteration: 4 });
199+
history.push({ name: "list_tabs", args: "{}", result: "[]", iteration: 5 });
200+
201+
// 使用 startIndex 后不再触发
202+
expect(detectToolCallIssues(history, startIndex)).toBeNull();
203+
});
204+
205+
it("startIndex 之后出现新的违规模式仍然能检测到", () => {
206+
const history: ToolCallRecord[] = [
207+
{ name: "get_tab_content", args: '{"tab_id":123,"prompt":"a"}', result: "...", iteration: 1 },
208+
{ name: "get_tab_content", args: '{"tab_id":123,"prompt":"b"}', result: "...", iteration: 2 },
209+
{ name: "get_tab_content", args: '{"tab_id":123,"prompt":"c"}', result: "...", iteration: 3 },
210+
];
211+
const startIndex = history.length;
212+
213+
// 新增的调用在 startIndex 之后再次触发相同问题
214+
history.push({ name: "get_tab_content", args: '{"tab_id":456,"prompt":"d"}', result: "...", iteration: 4 });
215+
history.push({ name: "get_tab_content", args: '{"tab_id":456,"prompt":"e"}', result: "...", iteration: 5 });
216+
history.push({ name: "get_tab_content", args: '{"tab_id":456,"prompt":"f"}', result: "...", iteration: 6 });
217+
218+
const warning = detectToolCallIssues(history, startIndex);
219+
expect(warning).not.toBeNull();
220+
expect(warning).toContain("get_tab_content");
221+
});
222+
});
223+
182224
describe("优先级", () => {
183225
it("完全相同参数的 execute_script 优先触发重复检测而非 null 检测", () => {
184226
const history: ToolCallRecord[] = [

src/app/service/agent/tool_call_guard.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,24 +133,29 @@ function checkGenericRepetition(history: ToolCallRecord[]): string | null {
133133
/**
134134
* 分析工具调用历史,检测重复/循环模式并生成针对性的提醒消息。
135135
* 按优先级检测,命中即返回。返回 null 表示没有检测到问题。
136+
*
137+
* @param startIndex 只检测 history[startIndex:] 的记录。
138+
* 调用方应在每次收到警告后将 startIndex 推进到当前 history.length,
139+
* 避免已经警告过的旧记录持续触发同一条警告。
136140
*/
137-
export function detectToolCallIssues(history: ToolCallRecord[]): string | null {
138-
if (history.length < 2) return null;
141+
export function detectToolCallIssues(history: ToolCallRecord[], startIndex = 0): string | null {
142+
const relevantHistory = startIndex > 0 ? history.slice(startIndex) : history;
143+
if (relevantHistory.length < 2) return null;
139144

140145
// 规则1: 完全相同的 tool + args
141-
const duplicateWarning = checkDuplicateCalls(history);
146+
const duplicateWarning = checkDuplicateCalls(relevantHistory);
142147
if (duplicateWarning) return duplicateWarning;
143148

144149
// 规则2: execute_script 连续返回 null
145-
const executeNullWarning = checkExecuteScriptNulls(history);
150+
const executeNullWarning = checkExecuteScriptNulls(relevantHistory);
146151
if (executeNullWarning) return executeNullWarning;
147152

148153
// 规则3: get_tab_content 对同一 tab 重复调用
149-
const getContentWarning = checkGetTabContentRepetition(history);
154+
const getContentWarning = checkGetTabContentRepetition(relevantHistory);
150155
if (getContentWarning) return getContentWarning;
151156

152157
// 规则4: 通用重复检测(兜底)
153-
const genericWarning = checkGenericRepetition(history);
158+
const genericWarning = checkGenericRepetition(relevantHistory);
154159
if (genericWarning) return genericWarning;
155160

156161
return null;

src/app/service/service_worker/agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,7 @@ export class AgentService {
12681268
let iterations = 0;
12691269
const totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
12701270
const toolCallHistory: ToolCallRecord[] = [];
1271+
let guardStartIndex = 0;
12711272

12721273
while (iterations < maxIterations) {
12731274
iterations++;
@@ -1423,8 +1424,10 @@ export class AgentService {
14231424
}
14241425

14251426
// 工具调用模式检测:检测重复/循环模式并注入针对性提醒
1426-
const toolCallWarning = detectToolCallIssues(toolCallHistory);
1427+
// 每次警告后推进 startIndex,避免旧记录持续触发同一条警告
1428+
const toolCallWarning = detectToolCallIssues(toolCallHistory, guardStartIndex);
14271429
if (toolCallWarning) {
1430+
guardStartIndex = toolCallHistory.length;
14281431
messages.push({ role: "user", content: toolCallWarning });
14291432
sendEvent({ type: "system_warning", message: toolCallWarning });
14301433
}

src/pages/options/routes/AgentChat/SubAgentBlock.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,13 @@ export default function SubAgentBlock({ state }: { state: SubAgentState }) {
9595
{state.isRunning ? (
9696
<span className="sub-agent-pulse" />
9797
) : (
98-
<span className="tw-w-[18px] tw-h-[18px] tw-rounded-full tw-flex tw-items-center tw-justify-center tw-bg-[rgba(var(--green-6),0.12)]">
98+
<span className="tw-w-[18px] tw-h-[18px] tw-rounded-full tw-flex tw-items-center tw-justify-center tw-bg-[rgba(var(--green-6),0.15)]">
9999
<IconCheck style={{ fontSize: 10, color: "rgb(var(--green-6))" }} />
100100
</span>
101101
)}
102102

103103
{/* 描述 */}
104-
<span className="tw-text-xs tw-font-medium tw-text-[var(--color-text-2)] tw-flex-1 tw-truncate">
104+
<span className="tw-text-sm tw-font-medium tw-text-[var(--color-text-1)] tw-flex-1 tw-truncate">
105105
{state.description}
106106
</span>
107107

Lines changed: 130 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,166 @@
1-
import { IconCheck, IconLoading, IconClockCircle } from "@arco-design/web-react/icon";
1+
import { useState } from "react";
2+
import { IconCheck, IconLoading, IconDown } from "@arco-design/web-react/icon";
23
import type { Task } from "@App/app/service/agent/tools/task_tools";
34

45
function TaskStatusIcon({ status }: { status: Task["status"] }) {
56
switch (status) {
67
case "completed":
78
return (
8-
<span className="tw-w-4 tw-h-4 tw-rounded-full tw-bg-[rgb(var(--green-1))] tw-flex tw-items-center tw-justify-center tw-shrink-0">
9-
<IconCheck style={{ fontSize: 10, color: "rgb(var(--green-6))" }} />
9+
<span className="tw-w-[18px] tw-h-[18px] tw-rounded-full tw-bg-[rgb(var(--green-6))] tw-flex tw-items-center tw-justify-center tw-shrink-0">
10+
<IconCheck style={{ fontSize: 10, color: "#fff" }} />
1011
</span>
1112
);
1213
case "in_progress":
1314
return (
14-
<span className="tw-w-4 tw-h-4 tw-flex tw-items-center tw-justify-center tw-shrink-0">
15-
<IconLoading style={{ fontSize: 12, color: "rgb(var(--arcoblue-6))" }} />
15+
<span
16+
className="tw-w-[18px] tw-h-[18px] tw-rounded-full tw-border-2 tw-border-solid tw-flex tw-items-center tw-justify-center tw-shrink-0"
17+
style={{ borderColor: "rgb(var(--arcoblue-6))" }}
18+
>
19+
<IconLoading style={{ fontSize: 10, color: "rgb(var(--arcoblue-6))" }} />
1620
</span>
1721
);
1822
default:
1923
return (
20-
<span className="tw-w-4 tw-h-4 tw-rounded-full tw-border tw-border-solid tw-border-[var(--color-border-2)] tw-bg-transparent tw-shrink-0" />
24+
<span className="tw-w-[18px] tw-h-[18px] tw-rounded-full tw-border-2 tw-border-solid tw-border-[var(--color-border-2)] tw-bg-transparent tw-shrink-0" />
2125
);
2226
}
2327
}
2428

2529
export default function TaskListBlock({ tasks }: { tasks: Task[] }) {
30+
const [collapsed, setCollapsed] = useState(false);
31+
2632
if (tasks.length === 0) return null;
2733

2834
const completed = tasks.filter((t) => t.status === "completed").length;
35+
const inProgress = tasks.filter((t) => t.status === "in_progress").length;
2936
const total = tasks.length;
37+
const progress = total > 0 ? (completed / total) * 100 : 0;
38+
const allDone = completed === total;
3039

3140
return (
32-
<div className="tw-my-3 tw-rounded-lg tw-border tw-border-solid tw-border-[var(--color-border-1)] tw-bg-[var(--color-fill-1)] tw-overflow-hidden">
33-
{/* 标题栏 */}
34-
<div className="tw-flex tw-items-center tw-justify-between tw-px-3 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--color-border-1)]">
35-
<div className="tw-flex tw-items-center tw-gap-1.5">
36-
<IconClockCircle style={{ fontSize: 12 }} className="tw-text-[var(--color-text-3)]" />
37-
<span className="tw-text-xs tw-font-medium tw-text-[var(--color-text-2)]">Tasks</span>
41+
<div className="tw-my-3 tw-rounded-xl tw-border tw-border-solid tw-border-[var(--color-border-1)] tw-bg-[var(--color-bg-2)] tw-overflow-hidden tw-shadow-sm">
42+
{/* 可点击的标题栏 */}
43+
<div
44+
className="tw-flex tw-items-center tw-gap-3 tw-px-4 tw-py-3 tw-cursor-pointer tw-select-none hover:tw-bg-[var(--color-fill-2)] tw-transition-colors tw-duration-150"
45+
onClick={() => setCollapsed((c) => !c)}
46+
>
47+
{/* 左侧:环形进度指示 + 文字 */}
48+
<div className="tw-flex tw-items-center tw-gap-2.5 tw-flex-1 tw-min-w-0">
49+
<ProgressRing progress={progress} allDone={allDone} />
50+
<div className="tw-flex tw-flex-col tw-min-w-0">
51+
<span className="tw-text-sm tw-font-medium tw-text-[var(--color-text-1)] tw-leading-tight">
52+
Tasks
53+
</span>
54+
<span className="tw-text-xs tw-text-[var(--color-text-3)] tw-leading-tight tw-mt-0.5">
55+
{allDone
56+
? `${total} 项任务已完成`
57+
: inProgress > 0
58+
? `正在执行 ${inProgress} 项,${completed}/${total} 已完成`
59+
: `${completed}/${total} 已完成`}
60+
</span>
61+
</div>
3862
</div>
39-
<span className="tw-text-xs tw-text-[var(--color-text-3)]">
40-
{completed}/{total}
41-
</span>
42-
</div>
4363

44-
{/* 进度条 */}
45-
<div className="tw-h-0.5 tw-bg-[var(--color-fill-3)]">
46-
<div
47-
className="tw-h-full tw-bg-[rgb(var(--green-6))] tw-transition-all tw-duration-300"
48-
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
64+
{/* 右侧:展开/收起箭头 */}
65+
<IconDown
66+
className="tw-text-[var(--color-text-3)] tw-transition-transform tw-duration-200 tw-shrink-0"
67+
style={{
68+
fontSize: 12,
69+
transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)",
70+
}}
4971
/>
5072
</div>
5173

52-
{/* 任务列表 */}
53-
<div className="tw-px-3 tw-py-1.5">
54-
{tasks.map((task) => (
55-
<div key={task.id} className="tw-flex tw-items-start tw-gap-2 tw-py-1.5">
56-
<div className="tw-mt-0.5">
74+
{/* 可收缩的任务列表 */}
75+
<div
76+
className="tw-transition-all tw-duration-200 tw-ease-in-out tw-overflow-hidden"
77+
style={{
78+
maxHeight: collapsed ? 0 : `${tasks.length * 40 + 16}px`,
79+
opacity: collapsed ? 0 : 1,
80+
}}
81+
>
82+
<div className="tw-border-t tw-border-solid tw-border-[var(--color-border-1)]" />
83+
<div className="tw-px-4 tw-py-2">
84+
{tasks.map((task, index) => (
85+
<div
86+
key={task.id}
87+
className="tw-flex tw-items-center tw-gap-2.5 tw-py-[7px]"
88+
style={{
89+
// 逐项淡入动画
90+
animation: "taskFadeIn 0.15s ease-out both",
91+
animationDelay: `${index * 30}ms`,
92+
}}
93+
>
5794
<TaskStatusIcon status={task.status} />
95+
<span
96+
className={`tw-text-[13px] tw-leading-normal tw-transition-colors tw-duration-200 ${
97+
task.status === "completed"
98+
? "tw-text-[var(--color-text-4)] tw-line-through"
99+
: task.status === "in_progress"
100+
? "tw-text-[var(--color-text-1)] tw-font-medium"
101+
: "tw-text-[var(--color-text-2)]"
102+
}`}
103+
>
104+
{task.subject}
105+
</span>
58106
</div>
59-
<span
60-
className={`tw-text-xs tw-leading-relaxed ${
61-
task.status === "completed"
62-
? "tw-text-[var(--color-text-4)] tw-line-through"
63-
: "tw-text-[var(--color-text-1)]"
64-
}`}
65-
>
66-
{task.subject}
67-
</span>
68-
</div>
69-
))}
107+
))}
108+
</div>
109+
</div>
110+
111+
{/* 底部进度条 */}
112+
<div className="tw-h-[3px] tw-bg-[var(--color-fill-2)]">
113+
<div
114+
className="tw-h-full tw-transition-all tw-duration-500 tw-ease-out tw-rounded-r-full"
115+
style={{
116+
width: `${progress}%`,
117+
background: allDone
118+
? "rgb(var(--green-6))"
119+
: "linear-gradient(90deg, rgb(var(--arcoblue-5)), rgb(var(--arcoblue-6)))",
120+
}}
121+
/>
70122
</div>
71123
</div>
72124
);
73125
}
126+
127+
/** 环形进度指示器 */
128+
function ProgressRing({ progress, allDone }: { progress: number; allDone: boolean }) {
129+
const size = 28;
130+
const strokeWidth = 2.5;
131+
const radius = (size - strokeWidth) / 2;
132+
const circumference = 2 * Math.PI * radius;
133+
const offset = circumference - (progress / 100) * circumference;
134+
135+
return (
136+
<svg
137+
width={size}
138+
height={size}
139+
className="tw-shrink-0"
140+
style={{ transform: "rotate(-90deg)" }}
141+
>
142+
{/* 背景圆环 */}
143+
<circle
144+
cx={size / 2}
145+
cy={size / 2}
146+
r={radius}
147+
fill="none"
148+
stroke="var(--color-fill-3)"
149+
strokeWidth={strokeWidth}
150+
/>
151+
{/* 进度圆环 */}
152+
<circle
153+
cx={size / 2}
154+
cy={size / 2}
155+
r={radius}
156+
fill="none"
157+
stroke={allDone ? "rgb(var(--green-6))" : "rgb(var(--arcoblue-6))"}
158+
strokeWidth={strokeWidth}
159+
strokeLinecap="round"
160+
strokeDasharray={circumference}
161+
strokeDashoffset={offset}
162+
className="tw-transition-all tw-duration-500 tw-ease-out"
163+
/>
164+
</svg>
165+
);
166+
}

src/pages/options/routes/AgentChat/styles.css

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,70 @@ body[arco-theme="dark"] .agent-edit-container {
457457
transform: scale(1);
458458
}
459459
}
460+
461+
/* 子代理卡片 */
462+
.sub-agent-card {
463+
background: var(--color-fill-1);
464+
border: 1px solid var(--color-border-2);
465+
border-left: 3px solid rgb(var(--arcoblue-5));
466+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
467+
}
468+
469+
body[arco-theme="dark"] .sub-agent-card {
470+
background: var(--color-fill-2);
471+
border-color: var(--color-border-3);
472+
border-left-color: rgb(var(--arcoblue-6));
473+
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.16);
474+
}
475+
476+
.sub-agent-header:hover {
477+
background: var(--color-fill-2);
478+
}
479+
480+
body[arco-theme="dark"] .sub-agent-header:hover {
481+
background: var(--color-fill-3);
482+
}
483+
484+
/* 子代理运行脉冲动画 */
485+
@keyframes sub-agent-pulse-ring {
486+
0% {
487+
box-shadow: 0 0 0 0 rgba(var(--arcoblue-6), 0.5);
488+
}
489+
70% {
490+
box-shadow: 0 0 0 6px rgba(var(--arcoblue-6), 0);
491+
}
492+
100% {
493+
box-shadow: 0 0 0 0 rgba(var(--arcoblue-6), 0);
494+
}
495+
}
496+
497+
.sub-agent-pulse {
498+
display: inline-block;
499+
width: 10px;
500+
height: 10px;
501+
border-radius: 50%;
502+
background: rgb(var(--arcoblue-6));
503+
flex-shrink: 0;
504+
animation: sub-agent-pulse-ring 1.5s ease-out infinite;
505+
}
506+
507+
/* 子代理内容区域 */
508+
.sub-agent-body {
509+
border-top: 1px solid var(--color-border-1);
510+
}
511+
512+
body[arco-theme="dark"] .sub-agent-body {
513+
border-top-color: var(--color-border-2);
514+
}
515+
516+
/* Task 列表项淡入动画 */
517+
@keyframes taskFadeIn {
518+
from {
519+
opacity: 0;
520+
transform: translateX(-6px);
521+
}
522+
to {
523+
opacity: 1;
524+
transform: translateX(0);
525+
}
526+
}

0 commit comments

Comments
 (0)