Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
d8360c4
🐛 修复小上下文窗口模型输入预算塌缩为 0 导致对话必然失败
cyfung1031 Jul 15, 2026
f1841ad
♻️ 合并 ToolLoopOrchestrator 包装层与基类为单一实现
cyfung1031 Jul 15, 2026
5fcb92d
♻️ 合并 ChatService 包装子类与基类为单一实现
cyfung1031 Jul 15, 2026
eff43a0
🐛 定时任务续接会话时纳入 agent-chat 会话队列锁,防止并发写覆盖消息
cyfung1031 Jul 15, 2026
47ec421
♻️ 合并 cat_agent 原型补丁覆盖层与基类为单一实现
cyfung1031 Jul 15, 2026
f648cc4
🎨 Settings 复用 agent_config 导出的最大工具调用次数上下限常量
cyfung1031 Jul 15, 2026
ef96056
🐛 provider 非取消失败时并入错误自带的本轮部分 usage
cyfung1031 Jul 16, 2026
59248ce
🐛 生成附件按"本轮租约"管理,未被消息引用前的退出路径统一回收
cyfung1031 Jul 16, 2026
6be9ea9
🐛 工具后处理阶段实时观察取消信号,附件/任务写入取消可靠化
cyfung1031 Jul 16, 2026
32af66a
🐛 compact 提交撞上 Stop 的 close() 窗口时补偿性回写原历史
cyfung1031 Jul 16, 2026
ded6c36
🐛 OPFS 仓库层跨上下文写锁 + 读取失败不再退化为默认快照
cyfung1031 Jul 17, 2026
8cc266a
🐛 会话队列连接感知化:排队前注册回调、入锁后复查、重入 clear 显式拒绝
cyfung1031 Jul 17, 2026
3da6099
🐛 脚本工具批次超时后按 requestId 通知客户端作废该批次
cyfung1031 Jul 17, 2026
67fb81f
⚡️ 图片按像素尺寸估算视觉 token,替代压缩字节数换算
cyfung1031 Jul 17, 2026
d5bff89
Merge branch 'main' into feat/ai-001
cyfung1031 Jul 17, 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
127 changes: 125 additions & 2 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 @@ -46,14 +48,14 @@ function createMockOPFS() {
if (opts?.create) {
store.set("__dir__" + name, new Map());
} else {
throw new Error("Not found");
throw new DOMException("A requested file or directory could not be found.", "NotFoundError");
}
}
return createMockDirHandle(store.get("__dir__" + name));
}),
getFileHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => {
if (!store.has(name) && !opts?.create) {
throw new Error("Not found");
throw new DOMException("A requested file or directory could not be found.", "NotFoundError");
}
if (!store.has(name)) {
store.set(name, "");
Expand Down Expand Up @@ -250,3 +252,124 @@ 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]);
});
});

describe("AgentChatRepo 跨上下文读-改-写安全(finding 1)", () => {
let repo: AgentChatRepo;
let rootStore: Map<string, any>;

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

it("并发 appendMessage 不应互相覆盖丢消息", async () => {
const convId = "conv-race";
const makeMessage = (id: string) => ({
id,
conversationId: convId,
role: "user" as const,
content: id,
createtime: Date.now(),
});

// 两个并发的读-改-写:无锁时双方都会读到空快照,后写者覆盖先写者
await Promise.all([repo.appendMessage(makeMessage("m1")), repo.appendMessage(makeMessage("m2"))]);

const stored = await repo.getMessages(convId);
expect(stored.map((m) => m.id).sort()).toEqual(["m1", "m2"]);
});

it("并发 saveConversation 不应互相覆盖丢会话", async () => {
const makeConv = (id: string) => ({
id,
title: id,
modelId: "m",
createtime: Date.now(),
updatetime: Date.now(),
});

await Promise.all([repo.saveConversation(makeConv("c1")), repo.saveConversation(makeConv("c2"))]);

const stored = await repo.listConversations();
expect(stored.map((c) => c.id).sort()).toEqual(["c1", "c2"]);
});

it("消息文件损坏时读取应抛错,而不是让后续写入基于空快照覆盖旧数据", async () => {
const messagesDir = navigateDir(rootStore, "agents", "conversations", "data");
messagesDir.set("conv-corrupt.json", "{ 损坏的 JSON");

await expect(repo.getMessages("conv-corrupt")).rejects.toThrow();
// appendMessage 的读阶段同样必须失败,绝不能把损坏文件当作空历史整份覆写
await expect(
repo.appendMessage({
id: "m1",
conversationId: "conv-corrupt",
role: "user",
content: "hi",
createtime: Date.now(),
})
).rejects.toThrow();
expect(messagesDir.get("conv-corrupt.json")).toBe("{ 损坏的 JSON");
});

it("文件尚未创建(NotFoundError)时读取返回默认值", async () => {
await expect(repo.getMessages("conv-none")).resolves.toEqual([]);
});

it("支持 Web Locks 的环境下写操作应在 navigator.locks 排它锁内执行", async () => {
const request = vi.fn(async (_name: string, _opts: unknown, fn: () => Promise<unknown>) => fn());
Object.defineProperty(navigator, "locks", {
value: { request },
configurable: true,
writable: true,
});
try {
await repo.appendMessage({
id: "m1",
conversationId: "conv-lock",
role: "user",
content: "hi",
createtime: Date.now(),
});
expect(request).toHaveBeenCalledWith(
expect.stringContaining("conv-lock"),
expect.objectContaining({ mode: "exclusive" }),
expect.any(Function)
);
} finally {
// @ts-expect-error 清理测试注入的 locks
delete navigator.locks;
}
});
});
89 changes: 54 additions & 35 deletions src/app/repo/agent_chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ export class AgentChatRepo extends OPFSRepo {
return this.readJsonFile<Conversation[]>(CONVERSATIONS_FILE, []);
}

// 保存/更新会话
// 保存/更新会话。
// conversations.json 被 Options 页与 Service Worker 两个上下文共享,所有读-改-写
// 都必须在同一把跨上下文排它锁内执行,否则双方会基于同一旧快照互相覆盖(见 finding 1)
async saveConversation(conversation: Conversation): Promise<void> {
const conversations = await this.readJsonFile<Conversation[]>(CONVERSATIONS_FILE, []);
const index = conversations.findIndex((c) => c.id === conversation.id);
if (index >= 0) {
conversations[index] = conversation;
} else {
conversations.unshift(conversation);
}
await this.writeJsonFile(CONVERSATIONS_FILE, conversations);
await this.withFileLock(CONVERSATIONS_FILE, async () => {
const conversations = await this.readJsonFile<Conversation[]>(CONVERSATIONS_FILE, []);
const index = conversations.findIndex((c) => c.id === conversation.id);
if (index >= 0) {
conversations[index] = conversation;
} else {
conversations.unshift(conversation);
}
await this.writeJsonFile(CONVERSATIONS_FILE, conversations);
});
}

// 删除会话及其消息和附件
Expand Down Expand Up @@ -64,12 +68,16 @@ export class AgentChatRepo extends OPFSRepo {
await this.deleteAttachments(attachmentIds);
}

const conversations = await this.readJsonFile<Conversation[]>(CONVERSATIONS_FILE, []);
const filtered = conversations.filter((c) => c.id !== id);
await this.writeJsonFile(CONVERSATIONS_FILE, filtered);
// 删除对应消息文件
const messagesDir = await this.getChildDir(MESSAGES_DIR);
await this.deleteFile(`${id}.json`, messagesDir);
await this.withFileLock(CONVERSATIONS_FILE, async () => {
const conversations = await this.readJsonFile<Conversation[]>(CONVERSATIONS_FILE, []);
const filtered = conversations.filter((c) => c.id !== id);
await this.writeJsonFile(CONVERSATIONS_FILE, filtered);
});
// 删除对应消息文件:与该会话消息文件的读-改-写同锁,避免并发 appendMessage 复活已删除文件
await this.withFileLock(`messages:${id}`, async () => {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
await this.deleteFile(`${id}.json`, messagesDir);
});
// 删除关联的任务数据
await this.deleteTasks(id).catch(() => {});
}
Expand All @@ -80,29 +88,38 @@ export class AgentChatRepo extends OPFSRepo {
return this.readJsonFile<ChatMessage[]>(`${conversationId}.json`, [], messagesDir);
}

// 追加消息
// 追加消息(读-改-写,须持有该会话消息文件的跨上下文排它锁,见 finding 1)
async appendMessage(message: ChatMessage): Promise<void> {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
const messages = await this.readJsonFile<ChatMessage[]>(`${message.conversationId}.json`, [], messagesDir);
messages.push(message);
await this.writeJsonFile(`${message.conversationId}.json`, messages, messagesDir);
await this.withFileLock(`messages:${message.conversationId}`, async () => {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
const messages = await this.readJsonFile<ChatMessage[]>(`${message.conversationId}.json`, [], messagesDir);
messages.push(message);
await this.writeJsonFile(`${message.conversationId}.json`, messages, messagesDir);
});
}

// 更新消息(按 id 匹配)
// 更新消息(按 id 匹配;读-改-写,同上须持锁
async updateMessage(message: ChatMessage): Promise<void> {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
const messages = await this.readJsonFile<ChatMessage[]>(`${message.conversationId}.json`, [], messagesDir);
const index = messages.findIndex((m) => m.id === message.id);
if (index >= 0) {
messages[index] = message;
await this.writeJsonFile(`${message.conversationId}.json`, messages, messagesDir);
}
await this.withFileLock(`messages:${message.conversationId}`, async () => {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
const messages = await this.readJsonFile<ChatMessage[]>(`${message.conversationId}.json`, [], messagesDir);
const index = messages.findIndex((m) => m.id === message.id);
if (index >= 0) {
messages[index] = message;
await this.writeJsonFile(`${message.conversationId}.json`, messages, messagesDir);
}
});
}

// 保存整个消息列表(用于批量更新)
async saveMessages(conversationId: string, messages: ChatMessage[]): Promise<void> {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir);
// 保存整个消息列表(用于批量更新)。整份覆写虽无读阶段,但仍须与其它读-改-写同锁排队,
// 否则可能穿插进别人临界区的读与写之间。
// signal 可选:传入时若在写入落定前已 abort,则放弃这次整份覆写而不是让它继续提交
// (OPFS createWritable() 本身是事务性的,写入的是临时副本,abort 不会影响已持久化的旧内容,见 finding 4)
async saveMessages(conversationId: string, messages: ChatMessage[], signal?: AbortSignal): Promise<void> {
await this.withFileLock(`messages:${conversationId}`, async () => {
const messagesDir = await this.getChildDir(MESSAGES_DIR);
await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir, signal);
});
}

// ---- 附件存储 ----
Expand Down Expand Up @@ -169,9 +186,11 @@ export class AgentChatRepo extends OPFSRepo {
}

// 保存会话关联的任务列表
async saveTasks(conversationId: string, tasks: Task[]): Promise<void> {
const tasksDir = await this.getChildDir(TASKS_DIR);
await this.writeJsonFile(`${conversationId}.json`, tasks, tasksDir);
async saveTasks(conversationId: string, tasks: Task[], signal?: AbortSignal): Promise<void> {
await this.withFileLock(`tasks:${conversationId}`, async () => {
const tasksDir = await this.getChildDir(TASKS_DIR);
await this.writeJsonFile(`${conversationId}.json`, tasks, tasksDir, 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
66 changes: 56 additions & 10 deletions src/app/repo/opfs_repo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
// OPFS(Origin Private File System)通用 Repo 基类
// 所有 Agent 相关的持久化数据统一存储在 agents/ 目录下

import { stackAsyncTask } from "@App/pkg/utils/async_queue";

const AGENTS_ROOT = "agents";

// 跨上下文互斥:Options 页与 Service Worker 都会直接读写同一份 OPFS JSON 文件,
// 进程内队列(stackAsyncTask)覆盖不了跨上下文的读-改-写竞争。Web Locks 按 origin
// 全局生效(扩展页与 MV3 SW 同源),是两者之间唯一共享的互斥原语;不支持 Web Locks
// 的环境(单元测试 jsdom)退化为进程内按 key 排队(见 finding 1)。
function withExclusiveFileLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const locks = (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks;
if (locks?.request) {
return locks.request(key, { mode: "exclusive" }, fn) as Promise<T>;
}
return stackAsyncTask(key, fn);
}

// 获取 agents 根目录
async function getAgentsRoot(): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory();
Expand Down Expand Up @@ -43,25 +57,57 @@ export class OPFSRepo {
return getSubDir(dir, childPath);
}

// 读取 JSON 文件,文件不存在时返回默认值
// 以当前 Repo + 逻辑范围(scope)为粒度的排它锁,供子类把"读-改-写"包成互斥临界区
protected withFileLock<T>(scope: string, fn: () => Promise<T>): Promise<T> {
return withExclusiveFileLock(`opfs-repo:${this.subPath}:${scope}`, fn);
}

// 读取 JSON 文件。文件尚未创建(NotFoundError)是预期状态,返回默认值;
// 解析失败、权限或 I/O 错误一律抛出——把这类失败静默转成默认值,会让后续的
// 读-改-写(appendMessage / saveConversation 等)基于空快照把仍然存在的旧数据
// 整份覆写掉(见 finding 1)。
protected async readJsonFile<T>(filename: string, defaultValue: T, dir?: FileSystemDirectoryHandle): Promise<T> {
const targetDir = dir || (await this.getDir());
let fileHandle: FileSystemFileHandle;
try {
const targetDir = dir || (await this.getDir());
const fileHandle = await targetDir.getFileHandle(filename);
const file = await fileHandle.getFile();
const text = await file.text();
return JSON.parse(text) as T;
} catch {
return defaultValue;
fileHandle = await targetDir.getFileHandle(filename);
} catch (error) {
if ((error as { name?: string })?.name === "NotFoundError") return defaultValue;
throw error;
}
const file = await fileHandle.getFile();
const text = await file.text();
// 空文件:createWritable() 事务性写入不会留下半截内容,空文件只可能是 create 后从未写入,
// 与"文件不存在"同义,按默认值处理
if (!text) return defaultValue;
return JSON.parse(text) as T;
}

// 写入 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.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
Loading
Loading