Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c6181b5
✨ Agent 对话最大工具调用次数可配置 + 达上限后可继续
cyfung1031 Jul 6, 2026
1185215
⚡️ Anthropic 消息历史缓存断点 + execute_script 超大返回值截断
cyfung1031 Jul 6, 2026
e6d5a50
⚡️ 长 tool loop 分批裁剪旧 tool 结果(滑动窗口)
cyfung1031 Jul 6, 2026
d85ba72
✨ 循环检测连续命中时暂停并询问用户是否继续
cyfung1031 Jul 6, 2026
dd1d162
Delete issue-1545-proposal.md
cyfung1031 Jul 6, 2026
de83fd3
🐛 修复代码审查发现的 4 处问题
cyfung1031 Jul 7, 2026
fddde4c
fix agent loop context accounting and cancellation
cyfung1031 Jul 11, 2026
19024ec
Update tool_loop_orchestrator.ts
cyfung1031 Jul 11, 2026
cc1261e
🐛 修复 Agent 对话续接与工具结果处理
cyfung1031 Jul 11, 2026
bb142ca
🐛 保持临时 Agent 对话消息语义
cyfung1031 Jul 11, 2026
ad31fd2
🐛 修复 Agent 任务终态与循环提示
cyfung1031 Jul 11, 2026
769c15b
🐛 修复 Agent 适配器终态与上下文预算
cyfung1031 Jul 11, 2026
256705d
🐛 修复 ask_user 终态与任务错误传播
cyfung1031 Jul 11, 2026
cbb6d8f
🐛 完善上下文与工具终态传播
cyfung1031 Jul 12, 2026
b335f5c
🐛 请求前预算检查,防止 tool 结果撑爆下一次请求
cyfung1031 Jul 12, 2026
b17e139
🐛 附件预算按 provider 实际内联行为估算,裁剪保留可恢复标识
cyfung1031 Jul 12, 2026
8c72058
🐛 修复用户脚本工具循环遗漏 tool_call_complete/new_message 事件
cyfung1031 Jul 12, 2026
7df9585
🐛 子代理原生异常补发终态事件,tool_call_complete 状态不再硬编码
cyfung1031 Jul 12, 2026
fc587b2
🐛 消除 ask_user 终态事件重复广播
cyfung1031 Jul 12, 2026
dc56064
fix(agent): harden userscript tool sessions
cyfung1031 Jul 12, 2026
700a848
脚本工具返回结果时,应继续工具循环并完成对话
cyfung1031 Jul 12, 2026
381b33d
🐛 fix sub-agent streaming and compact budgets
cyfung1031 Jul 12, 2026
41ff115
fix agent abort and summary budgeting
cyfung1031 Jul 12, 2026
bd6c0d5
🐛 修复 Agent 取消和用量统计
cyfung1031 Jul 12, 2026
ae540d4
🐛 修复后台会话取消竞态、压缩终止竞态与端口泄漏
cyfung1031 Jul 12, 2026
2f7467f
🐛 修复 compact_service 测试用例超时
cyfung1031 Jul 12, 2026
c4b7dfa
🐛 修复 processChat 在 done/error 时因 disconnect 时序被误判为连接断开
cyfung1031 Jul 12, 2026
7e7c1f2
⚡ 降低 tool loop 工具调用回写与 elideUntilWithinBudget 的常数开销
cyfung1031 Jul 12, 2026
58c7eb9
⚡ estimateRequestTokens 按消息缓存序列化字节数,消除 tool loop 的 O(n²) 重复计费
cyfung1031 Jul 12, 2026
2777cea
🐛 修复 SSE abort 悬挂、取消终态双主体竞态、compact 破坏性覆写与并发写入
cyfung1031 Jul 12, 2026
40ad4b0
🐛 修复取消终态清理漏判、SSE 截断误判成功、图片 token 严重高估等问题
cyfung1031 Jul 12, 2026
cd3c79d
🐛 修复取消 abort 悬挂、终态双主体竞态、compact 破坏性覆写与并发写入
cyfung1031 Jul 12, 2026
ee4a0bf
Merge branch 'main' into feat/ai-001
cyfung1031 Jul 14, 2026
bafbc17
Merge branch 'main' into feat/ai-001
cyfung1031 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/message/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type TMessagQueueUnit<T = any> = {
export type TMessageCommAction<T = any> = {
action: string;
data?: NonNullable<T>;
/** 可选请求关联 ID,用于连接上的请求/响应批次匹配。 */
requestId?: string;
msgQueue?: never;
code?: never;
};
Expand Down
33 changes: 33 additions & 0 deletions src/app/repo/agent_chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ function createMockOPFS() {
data = content;
}),
close: vi.fn(async () => {}),
// 真实 OPFS 的 abort() 放弃这次写入的临时副本,不影响 dir 里已提交的旧内容
abort: vi.fn(async () => {}),
getData: () => data,
};
}
Expand Down Expand Up @@ -250,3 +252,34 @@ describe("AgentChatRepo 附件存储", () => {
expect(await repo.getAttachment("att-del-2")).toBeNull();
});
});

describe("AgentChatRepo.saveMessages 取消安全(finding 4)", () => {
let repo: AgentChatRepo;

beforeEach(() => {
createMockOPFS();
repo = new AgentChatRepo();
});

it("signal 已 abort 时 saveMessages 应 reject 且不覆盖已持久化的旧消息", async () => {
const convId = "conv-cancel";
const oldMessage = {
id: "msg-old",
conversationId: convId,
role: "user" as const,
content: "原始历史",
createtime: Date.now(),
};
await repo.saveMessages(convId, [oldMessage]);

const controller = new AbortController();
controller.abort();
await expect(
repo.saveMessages(convId, [{ ...oldMessage, id: "msg-new", content: "摘要覆盖" }], controller.signal)
).rejects.toThrow("Aborted");

// 旧内容必须完整保留,没有被这次放弃的写入部分覆盖或破坏
const stored = await repo.getMessages(convId);
expect(stored).toEqual([oldMessage]);
});
});
8 changes: 5 additions & 3 deletions src/app/repo/agent_chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,12 @@ export class AgentChatRepo extends OPFSRepo {
}
}

// 保存整个消息列表(用于批量更新)
async saveMessages(conversationId: string, messages: ChatMessage[]): Promise<void> {
// 保存整个消息列表(用于批量更新)。
// signal 可选:传入时若在写入落定前已 abort,则放弃这次整份覆写而不是让它继续提交
// (OPFS createWritable() 本身是事务性的,写入的是临时副本,abort 不会影响已持久化的旧内容,见 finding 4)
async saveMessages(conversationId: string, messages: ChatMessage[], signal?: AbortSignal): Promise<void> {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir);
await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir, signal);
}

// ---- 附件存储 ----
Expand Down
5 changes: 3 additions & 2 deletions src/app/repo/agent_model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentModelConfig } from "@App/app/service/agent/core/types";
import { normalizeModelLimits } from "@App/app/service/agent/core/model_context";
import { Repo, loadCache } from "./repo";

const DEFAULT_MODEL_KEY = "agent_model:__default__";
Expand All @@ -21,9 +22,9 @@ export class AgentModelRepo extends Repo<AgentModelConfig> {
return this.get(id);
}

// 保存模型
// 保存模型:存储边界统一归一化 maxTokens/contextWindow(见 model_context.ts,finding 10)
async saveModel(model: AgentModelConfig): Promise<void> {
await this._save(model.id, model);
await this._save(model.id, normalizeModelLimits(model));
}

// 删除模型
Expand Down
23 changes: 21 additions & 2 deletions src/app/repo/opfs_repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,31 @@ export class OPFSRepo {
}
}

// 写入 JSON 文件
protected async writeJsonFile(filename: string, data: unknown, dir?: FileSystemDirectoryHandle): Promise<void> {
// 写入 JSON 文件。
// OPFS 的 createWritable() 本身是事务性的:write() 写入的是临时副本,只有 close() 成功
// 才会原子替换原文件;调用方持有的旧内容在此之前始终完整可读。
// 传入 signal 时,若在"调用 close() 之前"已 abort,则改为 writable.abort() 放弃这次写入。
// 诚实说明这里的边界:这只保证 close() 调用前的 abort 一定不会提交;一旦 close() 已经
// 发出,FSA 规范不提供可靠的方式中途取消它,abort 恰好落在 close() 进行期间这个极窄窗口
// 理论上仍可能提交。调用方(compact_service.ts / chat_service_base.ts)都会在
// saveMessages() resolve 之后再次检查 signal,因此即使命中这个窗口,也不会对外报告
// 虚假的成功事件(compact_done/done)——唯一的残留风险是磁盘内容被替换但会话已判定为
// 取消,这是一个已知的、极窄的边界情况,未做完整的事务回滚(见 finding 2)。
protected async writeJsonFile(
filename: string,
data: unknown,
dir?: FileSystemDirectoryHandle,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) throw new Error("Aborted");
const targetDir = dir || (await this.getDir());
const fileHandle = await targetDir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data));
if (signal?.aborted) {
await writable.abort().catch(() => {});
throw new Error("Aborted");
}
await writable.close();
}

Expand Down
32 changes: 32 additions & 0 deletions src/app/service/agent/core/abort_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** Create a standard abort error for tool execution flows. */
export function createAbortError(message = "Aborted"): Error {
return new Error(message);
}

/** Throw immediately when the signal is already aborted. */
export function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw createAbortError();
}
}

/** Reject as soon as the signal aborts, while leaving the original promise untouched. */
export function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(createAbortError());

return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(createAbortError());
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
}
);
});
}
31 changes: 17 additions & 14 deletions src/app/service/agent/core/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ describe("OpenAI Provider", () => {

it("应正确解析带 usage 的 done 事件", async () => {
const events = await collectEvents(parseOpenAIStream, [
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n',
'data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n',
]);

Expand Down Expand Up @@ -328,18 +328,17 @@ describe("OpenAI Provider", () => {
}
});

it("signal 中断时不应产生错误事件", async () => {
it("signal 中断时应 reject 而不是静默完成(否则外层 callLLM 永远挂起)", async () => {
const abortController = new AbortController();
abortController.abort();

const events = await collectEvents(
parseOpenAIStream,
['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'],
abortController.signal
);

// signal 已中断,不应处理任何数据
expect(events).toHaveLength(0);
await expect(
collectEvents(
parseOpenAIStream,
['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'],
abortController.signal
)
).rejects.toThrow("Aborted");
});
});
});
Expand Down Expand Up @@ -548,7 +547,7 @@ function buildSSEResponse(sseChunks: string[]): Response {
// 辅助:构造纯文本 SSE 数据(OpenAI 格式)
function makeTextSSE(text: string, usage?: { prompt_tokens: number; completion_tokens: number }): string[] {
const chunks: string[] = [];
chunks.push(`data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`);
chunks.push(`data: {"choices":[{"delta":{"content":"${text}"},"finish_reason":"stop"}]}\n\n`);
if (usage) {
chunks.push(`data: {"usage":${JSON.stringify(usage)}}\n\n`);
} else {
Expand All @@ -569,7 +568,7 @@ function makeToolCallSSE(
`data: {"choices":[{"delta":{"tool_calls":[{"id":"${toolId}","function":{"name":"${toolName}","arguments":""}}]}}]}\n\n`
);
chunks.push(
`data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"${args.replace(/"/g, '\\"')}"}}]}}]}\n\n`
`data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"${args.replace(/"/g, '\\"')}"}}]}, "finish_reason":"tool_calls"}]}\n\n`
);
if (usage) {
chunks.push(`data: {"usage":${JSON.stringify(usage)}}\n\n`);
Expand All @@ -588,6 +587,7 @@ function createTestService() {
listConversations: vi.fn().mockResolvedValue([]),
saveConversation: vi.fn().mockResolvedValue(undefined),
saveMessages: vi.fn().mockResolvedValue(undefined),
updateMessage: vi.fn().mockResolvedValue(undefined),
getAttachment: vi.fn().mockResolvedValue(null),
saveAttachment: vi.fn().mockResolvedValue(0),
});
Expand Down Expand Up @@ -699,7 +699,7 @@ describe("callLLMWithToolLoop", () => {
expect(fetchSpy).toHaveBeenCalledTimes(2);

// 工具应被执行
expect(mockExecutor.execute).toHaveBeenCalledWith({ city: "北京" });
expect(mockExecutor.execute).toHaveBeenCalledWith({ city: "北京" }, expect.any(AbortSignal));

// messages 应包含 assistant(tool_call) + tool(result) + user 原始消息
expect(messages.length).toBe(3); // user + assistant(toolCalls) + tool(result)
Expand Down Expand Up @@ -845,7 +845,10 @@ describe("callLLMWithToolLoop", () => {

// scriptCallback 应被调用
expect(scriptCallback).toHaveBeenCalledTimes(1);
expect(scriptCallback).toHaveBeenCalledWith([expect.objectContaining({ id: "call_1", name: "script_tool" })]);
expect(scriptCallback).toHaveBeenCalledWith(
[expect.objectContaining({ id: "call_1", name: "script_tool" })],
expect.any(AbortSignal)
);

expect(events.find((e) => e.type === "done")).toBeDefined();
});
Expand Down
83 changes: 83 additions & 0 deletions src/app/service/agent/core/agent_config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it, beforeEach } from "vitest";
import { AgentConfigRepo, DEFAULT_CHAT_MAX_ITERATIONS } from "./agent_config";

describe("AgentConfigRepo", () => {
let repo: AgentConfigRepo;

beforeEach(() => {
chrome.storage.local.clear();
repo = new AgentConfigRepo();
});

describe("getConfig", () => {
it("未保存过配置时应返回默认的 chatMaxIterations", async () => {
const config = await repo.getConfig();
expect(config.chatMaxIterations).toBe(DEFAULT_CHAT_MAX_ITERATIONS);
});

it("storage 读取异常时应返回默认配置而非抛出异常", async () => {
const originalGet = chrome.storage.local.get;
chrome.storage.local.get = () => Promise.reject(new Error("storage error"));
try {
const config = await repo.getConfig();
expect(config.chatMaxIterations).toBe(DEFAULT_CHAT_MAX_ITERATIONS);
} finally {
chrome.storage.local.get = originalGet;
}
});
});

describe("saveConfig", () => {
it("应持久化自定义的 chatMaxIterations 并可被重新读取", async () => {
await repo.saveConfig({ chatMaxIterations: 200 });
const config = await repo.getConfig();
expect(config.chatMaxIterations).toBe(200);
});

it("保存部分字段时应与默认配置合并而非丢失未指定字段", async () => {
await repo.saveConfig({ chatMaxIterations: 120 });
const config = await repo.getConfig();
expect(config).toEqual({ chatMaxIterations: 120 });
});

it("保存值超过上限 1000 时应截断为 1000", async () => {
await repo.saveConfig({ chatMaxIterations: 999999 });
const config = await repo.getConfig();
expect(config.chatMaxIterations).toBe(1000);
});

it("保存 0 或负数时应截断为下限 1", async () => {
await repo.saveConfig({ chatMaxIterations: 0 });
expect((await repo.getConfig()).chatMaxIterations).toBe(1);

await repo.saveConfig({ chatMaxIterations: -20 });
expect((await repo.getConfig()).chatMaxIterations).toBe(1);
});

it("保存非整数时应四舍五入", async () => {
await repo.saveConfig({ chatMaxIterations: 50.6 });
const config = await repo.getConfig();
expect(config.chatMaxIterations).toBe(51);
});
});

describe("边界防护 —— storage 中存在非法值(如损坏数据、devtools 直接写入、旧版本遗留值)", () => {
it("getConfig 读取到超出范围或非法的原始值时应归一化而非直接透传", async () => {
const cases: Array<[unknown, number]> = [
[0, 1],
[-5, 1],
[999999, 1000],
[NaN, DEFAULT_CHAT_MAX_ITERATIONS],
["not-a-number", DEFAULT_CHAT_MAX_ITERATIONS],
[null, DEFAULT_CHAT_MAX_ITERATIONS],
[12.9, 13],
];

for (const [stored, expected] of cases) {
await chrome.storage.local.set({ agent_general_config: { chatMaxIterations: stored } });
const config = await repo.getConfig();
expect(config.chatMaxIterations).toBe(expected);
}
});
});
});
40 changes: 40 additions & 0 deletions src/app/service/agent/core/agent_config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Agent 通用设置(对话行为相关,非 Skill/Model/Search 等专项配置)

export type AgentGeneralConfig = {
chatMaxIterations: number; // UI 对话的 tool calling 最大循环次数
};

const STORAGE_KEY = "agent_general_config";

export const DEFAULT_CHAT_MAX_ITERATIONS = 50;
export const MIN_CHAT_MAX_ITERATIONS = 1;
export const MAX_CHAT_MAX_ITERATIONS = 1000;

const DEFAULT_CONFIG: AgentGeneralConfig = {
chatMaxIterations: DEFAULT_CHAT_MAX_ITERATIONS,
};

// 归一化 chatMaxIterations:非法/非有限值回退默认值,其余四舍五入并截断到 [MIN, MAX]。
// 由 repo 统一把关,防止损坏的 storage、旧版本遗留值、或绕过 Settings UI 的调用方写入越界值。
// 同时导出供 ChatService 校验直接传入的 maxIterations(例如显式传入负数等非法值)。
export function normalizeChatMaxIterations(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_MAX_ITERATIONS;
return Math.min(MAX_CHAT_MAX_ITERATIONS, Math.max(MIN_CHAT_MAX_ITERATIONS, Math.round(value)));
}

export class AgentConfigRepo {
async getConfig(): Promise<AgentGeneralConfig> {
try {
const result = await chrome.storage.local.get(STORAGE_KEY);
const stored = result[STORAGE_KEY] as Partial<AgentGeneralConfig> | undefined;
return { chatMaxIterations: normalizeChatMaxIterations(stored?.chatMaxIterations) };
} catch {
return DEFAULT_CONFIG;
}
}

async saveConfig(config: AgentGeneralConfig): Promise<void> {
const normalized: AgentGeneralConfig = { chatMaxIterations: normalizeChatMaxIterations(config.chatMaxIterations) };
await chrome.storage.local.set({ [STORAGE_KEY]: normalized });
}
}
Loading
Loading