Skip to content

Commit adadc5c

Browse files
committed
✨ 实现 Agent 内置工具:web_fetch、web_search、ask_user、agent、task 管理
新增 8 个开箱即用的内置工具,为 Agent 提供基础能力层: - web_fetch: 抓取网页/JSON,Offscreen DOM 解析提取正文 - web_search: DuckDuckGo HTML 搜索 + Google Custom Search API - ask_user: 向用户提问并等待回复,5 分钟超时 - agent: 启动子代理执行独立子任务,排除 ask_user/agent 防止嵌套 - create_task/get_task/update_task/list_tasks: 会话内任务跟踪 基础设施: - Offscreen HTML 提取器(DOM 解析去骨架 + 搜索结果解析) - 搜索引擎配置 repo(chrome.storage) - ChatStreamEvent 新增 ask_user/sub_agent_event 类型 - System prompt 添加内置工具说明 - AskUserBlock UI 组件(提问展示 + 输入回复)
1 parent b00c148 commit adadc5c

21 files changed

Lines changed: 1559 additions & 5 deletions

src/app/service/agent/system_prompt.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,16 @@ Detect when you are stuck and stop early:
4242
- State what you will do before each action. Keep it to one short sentence.
4343
- When a task is blocked, explain the specific reason and what the user can do about it.
4444
- Keep responses concise — do not over-explain routine operations.
45-
- When reporting extracted data or results, format them clearly (use lists or structured text).`;
45+
- When reporting extracted data or results, format them clearly (use lists or structured text).
46+
47+
## Built-in Tools
48+
49+
You have direct access to these tools (no skill loading needed):
50+
- **web_fetch**: Fetch and extract content from a URL (HTML auto-extracted to readable text, JSON returned directly). Use for reading web pages, APIs, or downloading text content.
51+
- **web_search**: Search the web for information. Returns results with title, URL, and snippet.
52+
- **ask_user**: Ask the user a question and wait for their response. Use when you need clarification or a decision.
53+
- **agent**: Spawn a sub-agent for complex independent subtasks. The sub-agent has its own context and can use web_fetch, web_search, task tools, skills, and MCP tools. It cannot interact with the user directly.
54+
- **create_task / get_task / update_task / list_tasks**: Track multi-step work within this conversation. Tasks are in-memory only (not persisted across conversations).`;
4655

4756
// Skill 摘要提示词模板
4857
export const SKILL_SUFFIX_HEADER = `---
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { createAskUserTool } from "./ask_user";
3+
import type { ChatStreamEvent } from "@App/app/service/agent/types";
4+
5+
describe("ask_user", () => {
6+
it("should send ask_user event and resolve when answer is provided", async () => {
7+
const events: ChatStreamEvent[] = [];
8+
const sendEvent = (event: ChatStreamEvent) => events.push(event);
9+
const resolvers = new Map<string, (answer: string) => void>();
10+
11+
const { executor } = createAskUserTool(sendEvent, resolvers);
12+
13+
// Start execution (will block until resolved)
14+
const resultPromise = executor.execute({ question: "What color?" });
15+
16+
// Verify event was sent
17+
expect(events).toHaveLength(1);
18+
expect(events[0].type).toBe("ask_user");
19+
const askEvent = events[0] as Extract<ChatStreamEvent, { type: "ask_user" }>;
20+
expect(askEvent.question).toBe("What color?");
21+
22+
// Resolve the question
23+
expect(resolvers.size).toBe(1);
24+
const [askId, resolve] = Array.from(resolvers.entries())[0];
25+
resolve("Blue");
26+
27+
const result = await resultPromise;
28+
expect(JSON.parse(result as string)).toEqual({ answer: "Blue" });
29+
expect(resolvers.size).toBe(0);
30+
});
31+
32+
it("should throw if question is missing", async () => {
33+
const sendEvent = vi.fn();
34+
const resolvers = new Map<string, (answer: string) => void>();
35+
const { executor } = createAskUserTool(sendEvent, resolvers);
36+
37+
await expect(executor.execute({})).rejects.toThrow("question is required");
38+
});
39+
40+
it("should timeout after 5 minutes", async () => {
41+
vi.useFakeTimers();
42+
const sendEvent = vi.fn();
43+
const resolvers = new Map<string, (answer: string) => void>();
44+
45+
const { executor } = createAskUserTool(sendEvent, resolvers);
46+
const resultPromise = executor.execute({ question: "Waiting..." });
47+
48+
// Advance time past timeout
49+
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
50+
51+
await expect(resultPromise).rejects.toThrow("User did not respond within 5 minutes");
52+
expect(resolvers.size).toBe(0);
53+
54+
vi.useRealTimers();
55+
});
56+
57+
it("should generate unique ask IDs", async () => {
58+
const events: ChatStreamEvent[] = [];
59+
const sendEvent = (event: ChatStreamEvent) => events.push(event);
60+
const resolvers = new Map<string, (answer: string) => void>();
61+
62+
const { executor } = createAskUserTool(sendEvent, resolvers);
63+
64+
// Start two asks
65+
const p1 = executor.execute({ question: "Q1" });
66+
const p2 = executor.execute({ question: "Q2" });
67+
68+
expect(events).toHaveLength(2);
69+
const id1 = (events[0] as Extract<ChatStreamEvent, { type: "ask_user" }>).id;
70+
const id2 = (events[1] as Extract<ChatStreamEvent, { type: "ask_user" }>).id;
71+
expect(id1).not.toBe(id2);
72+
73+
// Resolve both
74+
for (const [id, resolve] of resolvers) {
75+
resolve("answer");
76+
}
77+
await Promise.all([p1, p2]);
78+
});
79+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ToolDefinition, ChatStreamEvent } from "@App/app/service/agent/types";
2+
import type { ToolExecutor } from "@App/app/service/agent/tool_registry";
3+
4+
export const ASK_USER_DEFINITION: ToolDefinition = {
5+
name: "ask_user",
6+
description:
7+
"Ask the user a question and wait for their response. Use this when you need clarification, a decision, or user input before proceeding. The user will see the question in the chat UI and can type a response.",
8+
parameters: {
9+
type: "object",
10+
properties: {
11+
question: { type: "string", description: "The question to ask the user" },
12+
},
13+
required: ["question"],
14+
},
15+
};
16+
17+
// 5 分钟超时
18+
const ASK_USER_TIMEOUT_MS = 5 * 60 * 1000;
19+
20+
export function createAskUserTool(
21+
sendEvent: (event: ChatStreamEvent) => void,
22+
resolvers: Map<string, (answer: string) => void>
23+
): { definition: ToolDefinition; executor: ToolExecutor } {
24+
let askCounter = 0;
25+
26+
const executor: ToolExecutor = {
27+
execute: async (args: Record<string, unknown>) => {
28+
const question = args.question as string;
29+
if (!question) {
30+
throw new Error("question is required");
31+
}
32+
33+
const askId = `ask_${Date.now()}_${++askCounter}`;
34+
35+
// 通知 UI 显示提问
36+
sendEvent({ type: "ask_user", id: askId, question });
37+
38+
// 等待用户回复
39+
return new Promise<string>((resolve, reject) => {
40+
const timer = setTimeout(() => {
41+
resolvers.delete(askId);
42+
reject(new Error("User did not respond within 5 minutes"));
43+
}, ASK_USER_TIMEOUT_MS);
44+
45+
resolvers.set(askId, (answer: string) => {
46+
clearTimeout(timer);
47+
resolvers.delete(askId);
48+
resolve(JSON.stringify({ answer }));
49+
});
50+
});
51+
},
52+
};
53+
54+
return { definition: ASK_USER_DEFINITION, executor };
55+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type SearchEngineConfig = {
2+
engine: "duckduckgo" | "google_custom";
3+
googleApiKey?: string;
4+
googleCseId?: string;
5+
};
6+
7+
const STORAGE_KEY = "agent_search_config";
8+
9+
const DEFAULT_CONFIG: SearchEngineConfig = {
10+
engine: "duckduckgo",
11+
};
12+
13+
export class SearchConfigRepo {
14+
async getConfig(): Promise<SearchEngineConfig> {
15+
try {
16+
const result = await chrome.storage.local.get(STORAGE_KEY);
17+
return result[STORAGE_KEY] || DEFAULT_CONFIG;
18+
} catch {
19+
return DEFAULT_CONFIG;
20+
}
21+
}
22+
23+
async saveConfig(config: SearchEngineConfig): Promise<void> {
24+
await chrome.storage.local.set({ [STORAGE_KEY]: config });
25+
}
26+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { createSubAgentTool } from "./sub_agent";
3+
4+
describe("sub_agent", () => {
5+
it("should call runSubAgent with correct parameters", async () => {
6+
const mockRunSubAgent = vi.fn().mockResolvedValue("Sub-agent result");
7+
8+
const { definition, executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent });
9+
10+
expect(definition.name).toBe("agent");
11+
12+
const result = await executor.execute({ prompt: "Search for X", description: "Searching X" });
13+
14+
expect(mockRunSubAgent).toHaveBeenCalledWith("Search for X", "Searching X");
15+
expect(result).toBe("Sub-agent result");
16+
});
17+
18+
it("should use default description if not provided", async () => {
19+
const mockRunSubAgent = vi.fn().mockResolvedValue("done");
20+
const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent });
21+
22+
await executor.execute({ prompt: "Do something" });
23+
expect(mockRunSubAgent).toHaveBeenCalledWith("Do something", "Sub-agent task");
24+
});
25+
26+
it("should throw if prompt is missing", async () => {
27+
const mockRunSubAgent = vi.fn();
28+
const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent });
29+
30+
await expect(executor.execute({})).rejects.toThrow("prompt is required");
31+
expect(mockRunSubAgent).not.toHaveBeenCalled();
32+
});
33+
34+
it("should propagate errors from runSubAgent", async () => {
35+
const mockRunSubAgent = vi.fn().mockRejectedValue(new Error("Agent failed"));
36+
const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent });
37+
38+
await expect(executor.execute({ prompt: "fail" })).rejects.toThrow("Agent failed");
39+
});
40+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ToolDefinition } from "@App/app/service/agent/types";
2+
import type { ToolExecutor } from "@App/app/service/agent/tool_registry";
3+
4+
export const SUB_AGENT_DEFINITION: ToolDefinition = {
5+
name: "agent",
6+
description:
7+
"Spawn a sub-agent to handle a complex, independent subtask. The sub-agent has its own conversation context and can use web_fetch, web_search, task tools, skills, and MCP tools. Use this for tasks that can be done independently without user interaction.",
8+
parameters: {
9+
type: "object",
10+
properties: {
11+
prompt: {
12+
type: "string",
13+
description: "The task description for the sub-agent. Be specific about what you need.",
14+
},
15+
description: {
16+
type: "string",
17+
description: "A short (3-5 word) description of what the sub-agent will do, shown in the UI.",
18+
},
19+
},
20+
required: ["prompt", "description"],
21+
},
22+
};
23+
24+
export function createSubAgentTool(params: {
25+
runSubAgent: (prompt: string, description: string) => Promise<string>;
26+
}): { definition: ToolDefinition; executor: ToolExecutor } {
27+
const executor: ToolExecutor = {
28+
execute: async (args: Record<string, unknown>) => {
29+
const prompt = args.prompt as string;
30+
const description = (args.description as string) || "Sub-agent task";
31+
32+
if (!prompt) {
33+
throw new Error("prompt is required");
34+
}
35+
36+
const result = await params.runSubAgent(prompt, description);
37+
return result;
38+
},
39+
};
40+
41+
return { definition: SUB_AGENT_DEFINITION, executor };
42+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, it, expect } from "vitest";
2+
import { createTaskTools } from "./task_tools";
3+
4+
describe("task_tools", () => {
5+
it("should create 4 tools", () => {
6+
const { tools } = createTaskTools();
7+
expect(tools).toHaveLength(4);
8+
const names = tools.map((t) => t.definition.name);
9+
expect(names).toEqual(["create_task", "get_task", "update_task", "list_tasks"]);
10+
});
11+
12+
it("create_task should create a task with auto-incremented ID", async () => {
13+
const { tools } = createTaskTools();
14+
const create = tools.find((t) => t.definition.name === "create_task")!;
15+
16+
const result1 = JSON.parse((await create.executor.execute({ subject: "Task 1" })) as string);
17+
expect(result1).toEqual({ id: "1", subject: "Task 1", status: "pending" });
18+
19+
const result2 = JSON.parse(
20+
(await create.executor.execute({ subject: "Task 2", description: "Details" })) as string
21+
);
22+
expect(result2).toEqual({ id: "2", subject: "Task 2", description: "Details", status: "pending" });
23+
});
24+
25+
it("get_task should return task or throw", async () => {
26+
const { tools } = createTaskTools();
27+
const create = tools.find((t) => t.definition.name === "create_task")!;
28+
const get = tools.find((t) => t.definition.name === "get_task")!;
29+
30+
await create.executor.execute({ subject: "Test" });
31+
const result = JSON.parse((await get.executor.execute({ task_id: "1" })) as string);
32+
expect(result.subject).toBe("Test");
33+
34+
await expect(get.executor.execute({ task_id: "999" })).rejects.toThrow('Task "999" not found');
35+
});
36+
37+
it("update_task should update fields", async () => {
38+
const { tools } = createTaskTools();
39+
const create = tools.find((t) => t.definition.name === "create_task")!;
40+
const update = tools.find((t) => t.definition.name === "update_task")!;
41+
42+
await create.executor.execute({ subject: "Original" });
43+
44+
const result = JSON.parse(
45+
(await update.executor.execute({ task_id: "1", status: "in_progress", subject: "Updated" })) as string
46+
);
47+
expect(result.status).toBe("in_progress");
48+
expect(result.subject).toBe("Updated");
49+
});
50+
51+
it("update_task should throw for non-existent task", async () => {
52+
const { tools } = createTaskTools();
53+
const update = tools.find((t) => t.definition.name === "update_task")!;
54+
await expect(update.executor.execute({ task_id: "1" })).rejects.toThrow();
55+
});
56+
57+
it("list_tasks should return all tasks summary", async () => {
58+
const { tools } = createTaskTools();
59+
const create = tools.find((t) => t.definition.name === "create_task")!;
60+
const list = tools.find((t) => t.definition.name === "list_tasks")!;
61+
62+
await create.executor.execute({ subject: "A" });
63+
await create.executor.execute({ subject: "B" });
64+
65+
const result = JSON.parse((await list.executor.execute({})) as string);
66+
expect(result).toHaveLength(2);
67+
expect(result[0]).toEqual({ id: "1", subject: "A", status: "pending" });
68+
expect(result[1]).toEqual({ id: "2", subject: "B", status: "pending" });
69+
});
70+
71+
it("list_tasks should return empty array initially", async () => {
72+
const { tools } = createTaskTools();
73+
const list = tools.find((t) => t.definition.name === "list_tasks")!;
74+
const result = JSON.parse((await list.executor.execute({})) as string);
75+
expect(result).toEqual([]);
76+
});
77+
78+
it("update_task should allow clearing description with empty string", async () => {
79+
const { tools } = createTaskTools();
80+
const create = tools.find((t) => t.definition.name === "create_task")!;
81+
const update = tools.find((t) => t.definition.name === "update_task")!;
82+
83+
await create.executor.execute({ subject: "Test", description: "Some desc" });
84+
85+
const result = JSON.parse(
86+
(await update.executor.execute({ task_id: "1", description: "" })) as string
87+
);
88+
expect(result.description).toBe("");
89+
});
90+
91+
it("update_task with only task_id should not change anything", async () => {
92+
const { tools } = createTaskTools();
93+
const create = tools.find((t) => t.definition.name === "create_task")!;
94+
const update = tools.find((t) => t.definition.name === "update_task")!;
95+
96+
await create.executor.execute({ subject: "Original", description: "Desc" });
97+
98+
const result = JSON.parse(
99+
(await update.executor.execute({ task_id: "1" })) as string
100+
);
101+
expect(result.subject).toBe("Original");
102+
expect(result.description).toBe("Desc");
103+
expect(result.status).toBe("pending");
104+
});
105+
106+
it("create_task without description should not include it in result", async () => {
107+
const { tools } = createTaskTools();
108+
const create = tools.find((t) => t.definition.name === "create_task")!;
109+
110+
const result = JSON.parse((await create.executor.execute({ subject: "No desc" })) as string);
111+
expect(result.description).toBeUndefined();
112+
});
113+
114+
it("tasks map should be independent per createTaskTools call", async () => {
115+
const instance1 = createTaskTools();
116+
const instance2 = createTaskTools();
117+
118+
const create1 = instance1.tools.find((t) => t.definition.name === "create_task")!;
119+
const list2 = instance2.tools.find((t) => t.definition.name === "list_tasks")!;
120+
121+
await create1.executor.execute({ subject: "Only in instance1" });
122+
123+
const result = JSON.parse((await list2.executor.execute({})) as string);
124+
expect(result).toEqual([]);
125+
expect(instance1.tasks.size).toBe(1);
126+
expect(instance2.tasks.size).toBe(0);
127+
});
128+
});

0 commit comments

Comments
 (0)