From 0508f3418f3df50f3a0bd5d30044bb412372e5c0 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 17:58:08 +0800 Subject: [PATCH 1/6] feat(agent): require Chinese thinking and reports in subagent prompts (AG-4) Subagent / vision subflow prompts (audience=agent) get a dedicated section: report goes to the parent session, and thinking, step notes and reports stay in Chinese even when the goal, tool results or web pages are in English. Prompt regression test covers both the shared hard rule and the new section. Child reports reaching the user are already humanized: the child session page uses the same FIX-7 report-humanize path, and the parent's final report resolves child run IDs to their goal titles; agent.spawn/await tool rows show no raw summary. Co-Authored-By: Claude Opus 5.5 --- packages/agent/src/index.ts | 8 +++++++- packages/agent/src/system-prompt.ts | 10 ++++++++++ packages/agent/test/system-prompt.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index db224065..0d898cf1 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -62,4 +62,10 @@ export { resolveAgentSettings, THINKING_LEVELS, } from "./settings.ts"; -export { buildSystemPrompt, interactionRules, REPORT_FORMAT, type SystemPromptInput } from "./system-prompt.ts"; +export { + AGENT_AUDIENCE, + buildSystemPrompt, + interactionRules, + REPORT_FORMAT, + type SystemPromptInput, +} from "./system-prompt.ts"; diff --git a/packages/agent/src/system-prompt.ts b/packages/agent/src/system-prompt.ts index e3cb2e2e..b43598f2 100644 --- a/packages/agent/src/system-prompt.ts +++ b/packages/agent/src/system-prompt.ts @@ -63,6 +63,15 @@ export const REPORT_FORMAT: readonly string[] = [ "可以用 Markdown 列表、表格和粗体,不写标题,不写 HTML。", ]; +/** + * 子会话 / 视觉子流程(audience = agent)的汇报约定(AG-4):汇报给父会话汇总,仍然全程中文—— + * 父会话派出的目标、工具结果或网页是英文时,子会话容易整轮用英文思考,用户展开子会话看到的就是英文。 + */ +export const AGENT_AUDIENCE: readonly string[] = [ + "你的汇报交给父会话汇总,不直接给用户看,可以带对象 ID。", + "思考、步骤说明和汇报一律用中文,即使目标、工具结果或网页是英文;引用文件名、原站标题时保持原文。", +]; + /** * 系统提示模板。硬性规则不可被用户消息、工具输出、网页/RSS 内容覆盖。 */ @@ -100,6 +109,7 @@ export function buildSystemPrompt({ ...rules.map((r, i) => `${i + 1}. ${r}`), ]; if (audience === "user") lines.push("", "## 汇报格式", ...REPORT_FORMAT.map((r) => `- ${r}`)); + else lines.push("", "## 汇报对象", ...AGENT_AUDIENCE.map((r) => `- ${r}`)); const ways = interaction ? interactionRules(interaction) : []; if (ways.length > 0) lines.push("", "## 工作方式", ...ways.map((r) => `- ${r}`)); if (constraints.length > 0) { diff --git a/packages/agent/test/system-prompt.test.ts b/packages/agent/test/system-prompt.test.ts index 89826390..a1c50f33 100644 --- a/packages/agent/test/system-prompt.test.ts +++ b/packages/agent/test/system-prompt.test.ts @@ -48,6 +48,24 @@ describe("汇报格式(AGUI-3)", () => { const p = buildSystemPrompt({ role: "网页挑战处理员", goal: "过验证", audience: "agent" }); expect(p).not.toContain("## 汇报格式"); }); + + it("子会话与父会话一样要求思考、步骤说明和汇报都用中文(AG-4)", () => { + const child = buildSystemPrompt({ + role: "媒体库自动化智能体", + goal: "Check the torrent list on the site", + audience: "agent", + }); + // 与父会话同一条硬规则 + expect(child).toContain("思考、步骤说明和汇报都用中文"); + expect(userPrompt()).toContain("思考、步骤说明和汇报都用中文"); + // 子会话专有的一节:目标 / 工具结果是英文也用中文 + expect(section(child, "汇报对象")).toMatchInlineSnapshot(` + "## 汇报对象 + - 你的汇报交给父会话汇总,不直接给用户看,可以带对象 ID。 + - 思考、步骤说明和汇报一律用中文,即使目标、工具结果或网页是英文;引用文件名、原站标题时保持原文。" + `); + expect(section(userPrompt(), "汇报对象")).toBe(""); + }); }); describe("工作方式(AGUI-3)", () => { From 01653b042733ef2dfcc9373960919f0a9d7b0887 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 17:58:08 +0800 Subject: [PATCH 2/6] feat(agent): dedupe agent.spawn by normalized goal within a parent session (AG-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spawn whose goal (NFKC, case, whitespace and punctuation folded) matches a child of the same parent that is still queued/running, or succeeded/waiting within the last 10 minutes, returns that child instead of creating a new one: output gets reused=true, a message ("已有同目标子会话…") and the child's current result summary. Failed/cancelled/over-budget children are not reused. Spawn decisions are serialized per parent in-process so parallel duplicate calls in one batch create a single child. Replays keyed by idempotency still hit the original child as before. The subagent constraint in the system prompt asks for Chinese sub-goals and no duplicate spawns. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/agent/runner.ts | 2 +- apps/server/src/modules/agent/subagents.ts | 89 +++++++++++++++++++++- apps/server/test/e2e/e11-subagents.test.ts | 82 ++++++++++++++++++++ packages/contracts/src/capabilities-io.ts | 7 ++ packages/contracts/src/capability.ts | 2 +- 5 files changed, 178 insertions(+), 4 deletions(-) diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index 85aed158..4c4fcdb8 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -271,7 +271,7 @@ function systemPromptFor( constraints.push( `可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` + "子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;budget 的每一项都必须小于本会话剩余预算(超过会被拒绝,不给则按剩余的一半划拨);" + - "派生后用 agent.await 等结果再汇总,不要为小事派生。", + "派生后用 agent.await 等结果再汇总,不要为小事派生;子目标用中文写;同一目标已派出过的子会话不要重复派生(会直接返回已有子会话)。", ); if (scope.capabilities.includes("web.search")) constraints.push( diff --git a/apps/server/src/modules/agent/subagents.ts b/apps/server/src/modules/agent/subagents.ts index 9ae30e59..44b2ab2d 100644 --- a/apps/server/src/modules/agent/subagents.ts +++ b/apps/server/src/modules/agent/subagents.ts @@ -49,6 +49,44 @@ const ALL_KINDS: CapabilityKind[] = ["read", "preview", "propose", "approve", "e const PLAN_ONLY_KINDS: CapabilityKind[] = ["read", "preview", "propose"]; /** 一个会话最多派生的子会话数(预算之外的兜底) */ export const MAX_CHILDREN_PER_RUN = 10; +/** 同目标去重窗口(AG-4):已完成的子会话在这段时间内按同目标复用 */ +export const SPAWN_DEDUPE_WINDOW_MS = 10 * 60_000; + +/** 目标归一化:全半角统一、忽略大小写、空白与标点("查一下 观众 站点。" ≡ "查一下观众站点") */ +export function normalizeGoal(goal: string): string { + return goal + .normalize("NFKC") + .toLowerCase() + .replace(/[\s\p{P}\p{S}]+/gu, ""); +} + +/** + * 同一父会话里同目标、可复用的子会话:还没落定(排队 / 运行中),或 10 分钟内成功完成 / 已在等下载入库。 + * 失败、取消、超预算的不复用(父会话可以换个预算或说法重派)。 + */ +export async function findSameGoalChild( + db: Kysely, + parentId: string, + goal: string, + now = Date.now(), +): Promise { + const key = normalizeGoal(goal); + if (!key) return null; + const rows = await db + .selectFrom("agent_run") + .selectAll() + .where("parent_run_id", "=", parentId) + .orderBy("created_at", "desc") + .execute(); + for (const r of rows) { + if (normalizeGoal(r.goal) !== key) continue; + if (!SETTLED.includes(r.status)) return r; + if (r.status !== "succeeded" && r.status !== "waiting") continue; + const at = (r.finished_at ?? r.updated_at)?.getTime() ?? 0; + if (now - at <= SPAWN_DEDUPE_WINDOW_MS) return r; + } + return null; +} const MIRRORED_TYPES = [ "run_started", "tool_call_start", @@ -690,6 +728,23 @@ export async function suspendRun(live: LiveRun, sus: AwaitSuspension): Promise>(); +/** 同一父会话的派生判定串行(进程内);返回释放函数 */ +async function spawnLock(parentId: string): Promise<() => void> { + const prev = spawnLocks.get(parentId) ?? Promise.resolve(); + let release!: () => void; + const mine = new Promise((r) => { + release = r; + }); + const tail = prev.then(() => mine); + spawnLocks.set(parentId, tail); + await prev; + return () => { + release(); + if (spawnLocks.get(parentId) === tail) spawnLocks.delete(parentId); + }; +} + export interface SpawnDeps { /** 建子 agent_run + 任务行并入队 runAgent(service.startRun) */ start(p: { @@ -728,8 +783,19 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx: .selectAll() .where("dedupe_key", "=", dedupeKey) .executeTakeFirst(); + // AG-4 去重:不是重放(幂等键没命中)时,同目标的子会话还在跑或刚完成就直接复用。 + // 同一父会话的派生串行(同一批并行的两次同目标调用也只建一个);父会话只在一个进程里运行 + const release = await spawnLock(parent.id); let child = existing; - if (!child) { + let reused: AgentRunRow | null = null; + try { + reused = !child ? await findSameGoalChild(ctx.db, parent.id, input.goal) : null; + if (reused) child = reused; + if (!child) child = await create(); + } finally { + release(); + } + async function create(): Promise { if (parent.depth + 1 > cfg.maxDepth) throw new CapabilityError("precondition", `派生层数已达上限 ${cfg.maxDepth},子会话不能再派生`); const mode: AgentRunMode = input.mode ?? parent.mode; @@ -753,7 +819,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx: reason: input.reason ?? null, dedupeKey, }); - child = r.run; + return r.run; } await liveRun(parent.id)?.pump(); let result: SubagentResult | null = null; @@ -774,6 +840,25 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx: } } const cs = (child.scope ?? {}) as Partial; + if (reused) { + // 没要求等待时也带上已有子会话的当前结果(还在跑的 summary 为 null、pending = true) + if (!result) { + const row = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", child.id).executeTakeFirst(); + result = resultOf(row ?? child); + } + return { + reused: true, + message: result.pending + ? "已有同目标子会话,仍在运行,未新建;需要结果时用 agent.await 等它" + : "已有同目标子会话(10 分钟内已完成),未新建;结果见 result", + runId: child.id, + depth: child.depth, + mode: child.mode, + capabilities: cs.capabilities ?? [], + budget: (child.budget ?? {}) as SubagentBudget, + result, + }; + } return { runId: child.id, depth: child.depth, diff --git a/apps/server/test/e2e/e11-subagents.test.ts b/apps/server/test/e2e/e11-subagents.test.ts index ac69ec35..38719d84 100644 --- a/apps/server/test/e2e/e11-subagents.test.ts +++ b/apps/server/test/e2e/e11-subagents.test.ts @@ -358,6 +358,88 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => { await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "in", [parent.id, child.id]).execute(); }, 60_000); + it("同目标去重(AG-4):子会话还在运行或 10 分钟内已完成时返回已有子会话,不新建", async () => { + const parent = await e.db + .insertInto("agent_run") + .values({ + trigger: "user", + goal: "fixture dedupe parent", + status: "running", + mode: "plan_only", + scope: JSON.stringify({ + capabilities: [...READONLY, "agent.spawn", "agent.await"], + allowedKinds: ["read", "preview", "propose"], + }), + budget: JSON.stringify({}), + created_by: "user:admin", + }) + .returningAll() + .executeTakeFirstOrThrow(); + const child = await e.db + .insertInto("agent_run") + .values({ + trigger: "subagent", + goal: "子目标:查《沙丘》 缺什么版本", + status: "running", + mode: "plan_only", + scope: JSON.stringify({ capabilities: READONLY, allowedKinds: ["read", "preview", "propose"] }), + budget: JSON.stringify({ maxToolCalls: 5 }), + created_by: "user:admin", + parent_run_id: parent.id, + depth: 1, + started_at: new Date(), + }) + .returningAll() + .executeTakeFirstOrThrow(); + const spawn = async (input: Record, key: string) => + (await e.booted.ctx.capabilities.invoke("agent.spawn", input, { + actorId: "user:admin", + taskId: parent.id, + scopeIds: [], + signal: new AbortController().signal, + source: "agent", + agentRunId: parent.id, + invocationId: key, + })) as Any; + const children = async () => + (await e.db.selectFrom("agent_run").select("id").where("parent_run_id", "=", parent.id).execute()).map( + (x) => x.id, + ); + + // 仍在运行:归一化后同目标(全半角标点、空白不同)→ 复用,结果摘要 pending + const a = await spawn({ goal: "子目标:查《沙丘》缺什么版本。", capabilityAllowlist: READONLY }, "dedupe-1"); + expect(a).toMatchObject({ reused: true, runId: child.id, result: { pending: true, summary: null } }); + expect(a.message).toContain("已有同目标子会话"); + expect(Object.keys(a)[0]).toBe("reused"); + + // 5 分钟前已完成:复用并带回汇报摘要 + await e.db + .updateTable("agent_run") + .set({ status: "succeeded", summary: "《沙丘》缺 4K 版本", finished_at: new Date(Date.now() - 5 * 60_000) }) + .where("id", "=", child.id) + .execute(); + const b = await spawn({ goal: "子目标:查《沙丘》缺什么版本" }, "dedupe-2"); + expect(b).toMatchObject({ + reused: true, + runId: child.id, + result: { status: "succeeded", pending: false, summary: expect.stringContaining("缺 4K") }, + }); + expect(b.message).toContain("10 分钟内已完成"); + // 结果行的子会话计数(会话树)不变 + expect(await children()).toEqual([child.id]); + + // 超过 10 分钟 / 失败的不复用;不同目标不复用 + const { findSameGoalChild, normalizeGoal } = await import("../../src/modules/agent/subagents.ts"); + expect(normalizeGoal("子目标:查《沙丘》 缺什么版本。")).toBe(normalizeGoal("子目标:查《沙丘》缺什么版本")); + const later = Date.now() + 6 * 60_000; + expect(await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本", later)).toBeNull(); + expect((await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本"))?.id).toBe(child.id); + expect(await findSameGoalChild(e.db, parent.id, "子目标:查《奥本海默》缺什么版本")).toBeNull(); + await e.db.updateTable("agent_run").set({ status: "failed" }).where("id", "=", child.id).execute(); + expect(await findSameGoalChild(e.db, parent.id, "子目标:查《沙丘》缺什么版本")).toBeNull(); + await e.db.updateTable("agent_run").set({ status: "cancelled" }).where("id", "=", parent.id).execute(); + }, 60_000); + it("父会话中止 → 全部子会话 cancelled(排队等名额的也一样)", async () => { // 先停 worker 建父会话,再塞一个占着唯一名额的子会话,保证真实子会话排队等名额 await e.worker?.stop(); diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index fc53576f..c592eca9 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -390,6 +390,13 @@ export const SubagentResult = z.object({ pending: z.boolean(), }); export const AgentSpawnOutput = z.object({ + /** + * AG-4 去重:同一父会话里同一目标(归一化后)的子会话还在运行或 10 分钟内已完成时不新建, + * 返回已有子会话(reused = true,result 带它的当前结果摘要);界面的"派出 N 个子会话"不计这次调用 + */ + reused: z.boolean().optional(), + /** 给模型的一句说明(reused 时:"已有同目标子会话…") */ + message: z.string().optional(), runId: Id, depth: NonNegInt, mode: z.enum(["execute", "plan_only"]), diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index 2b66d4e1..94b5ec9d 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -347,7 +347,7 @@ export const CAPABILITY_CATALOG: Record "agent.spawn": d( "agent.spawn", "派生子会话", - "把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报", + "把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报。同一目标的子会话还在运行或 10 分钟内已完成时不新建,直接返回已有子会话(reused=true)", "propose", "AgentSpawn", "organize.execute", From 793b87e3b66ff5391eb15f77c8feb0a2c1cfb0da Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 17:58:08 +0800 Subject: [PATCH 3/6] docs(changelog): prune stale known limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop "site rate limit counted per process; cross-process token bucket in progress on c2a": migration 0041 shipped the PG token bucket (61f585a). - "原站视图是 1.5 秒截图轮询,没有在真实 camofox 上核验": desktop mode (WT-1) exists and was verified on real camofox (v0.2.4 notes); keep only the "HTTP failure does not fall back to the browser" part, still true. - HHClub was checked against the real site in VER-3 / HH-2; keep 观众、馒头、CHD. - Other entries re-checked against 11-requirements-coverage and code: still hold. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cd18d3..98243e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,10 +109,9 @@ - 旧库只有库根"只登记 / 纳管"两种模式,没有首次扫描登记和启用前的影响预览。 - Agent 不做主观选片,也没有规则学习与样本回放;策略预览只对最近 200 条已识别资源试算。 - Emby 查询结果不参与收录决策,也没有本地资产与 Emby 的对账。 -- 站点 HTTP 访问失败不会自动转浏览器;原站视图是 1.5 秒截图轮询,没有在真实 camofox 上核验。 -- 观众、馒头、HHClub、CHD 只对 mock 与解析样本验证过,没有连真实账号。 +- 站点 HTTP 访问失败不会自动转浏览器。 +- 观众、馒头、CHD 只对 mock 与解析样本验证过,没有连真实账号。 - 用户委托换源时,新提交与原任务的处置不在同一个计划里。 -- 站点限流按进程计数,api 与 worker 各算各的;跨进程令牌桶在 c2a 分支进行中。 - 定时任务里的"备份"是占位,备份靠宿主机 cron 调 `umt backup`,恢复没有自动化演练。 - 没有身份巡检;同一挂载根离线引起的多条待处理不按根因合并。 - 没有调查整个 qB 实例(含未纳管任务)的能力。 From 919dc022007b931f9362916cb4fda2ce3133b0d2 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 17:58:15 +0800 Subject: [PATCH 4/6] feat(web): session stats line, live turn status, drop replaced-ID list (AG-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the "换掉的标识" mapping list under humanized reports; the raw report text is kept only as "原文" in the result line's 技术信息 (no mapping). - Session stats line above the overview bar, always shown: tool calls, MCP calls (only when any), tokens used and left to the budget (K/M + percent), cost; live while running, fixed after the run ends. - Live turn status: "思考中 / 调用工具中 / 回答中 · 已调用 N 次工具、M 次 MCP"; after the turn the count merges into each progress line ("调用 N 次工具"). - agent.spawn calls answered with an existing same-goal child (reused=true) are not counted in "派出了 N 个子会话". Screenshots (desktop + mobile): apps/web/test/screenshots/ag4/ (gitignored). Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 69 +++++++++---------- .../app/components/agent/agent-run-panel.tsx | 44 +++++++++++- apps/web/app/components/agent/agui3.test.ts | 54 ++++++++++++++- .../components/agent/conversation-model.ts | 68 ++++++++++++++++++ .../components/agent/report-humanize.test.ts | 67 +++++++++++++++++- 5 files changed, 263 insertions(+), 39 deletions(-) diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index ba7a00dc..3bb8e1d8 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -54,10 +54,13 @@ import { activitySummary, authDenial, buildConversation, + callCounts, + callCountText, capabilityTitle, deniedReason, durationLabel, isDenied, + lastReportText, runningText, toolSummary, } from "./conversation-model.ts"; @@ -271,6 +274,7 @@ export function StepSummary({ const denied = calls.filter(isDenied).length; const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length; const span = activitySpan(activity); + const counted = running ? "" : callCountText(callCounts(calls), "调用"); const thinking = activity.some((a) => a.kind === "thinking"); const head = current ? `${done ? `${done},` : ""}${runningText(current)}…` @@ -292,6 +296,7 @@ export function StepSummary({ {head} {denied ? · {denied} 项没做 : null} {failed ? · {failed} 项失败 : null} + {counted ? · {counted} : null} {span !== null && !running ? · {durationLabel(span)} : null} @@ -473,15 +478,32 @@ export function AgentConversation({ // 等选方案 / 等审批时卡片本身就是提示,不再显示"等待中…" const lastPart = lastBlock?.kind === "turn" ? lastBlock.parts.at(-1) : undefined; const cardWaiting = run.status === "waiting" && (lastPart?.kind === "options" || lastPart?.kind === "approval"); - const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting; + // 本轮的实时状态(AG-4):「思考中 · 已调用 3 次工具、1 次 MCP」;本轮结束后计数并入各段进度行 + const turnCalls = + lastBlock?.kind === "turn" + ? lastBlock.parts.flatMap((p) => + p.kind === "segment" ? p.activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])) : [], + ) + : []; + const turnCount = callCountText(callCounts(turnCalls)); + const showTyping = active && !cardWaiting; + const typingState = + run.status === "waiting" + ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}` + : run.status === "queued" + ? "排队中" + : toolRunning + ? "调用工具中" + : tail?.kind === "text" + ? "回答中" + : "思考中"; const typing = showTyping ? ( - - - {run.status === "waiting" - ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` - : run.status === "queued" - ? "排队中…" - : "正在处理…"} + + + + {typingState} + {turnCount ? ` · ${turnCount}` : "…"} + ) : null; @@ -521,7 +543,6 @@ export function AgentConversation({ {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {human ? : t.text} - {human?.replaced.length ? : null} ); })} @@ -565,32 +586,6 @@ export function AgentConversation({ ); } -/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */ -function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) { - const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()]; - return ( - - {uniq.map((r) => ( -
  • - {r.from} → {r.to || "(省略)"} -
  • - ))} - - ), - wide: true, - }, - ]} - /> - ); -} - // ── 实际结果 ── /** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */ @@ -655,6 +650,8 @@ export function ResultLine({ ] : []; const bad = report?.state === "failed" || report?.state === "partial"; + const last = lastReportText(live.items); + const rawReport = last && humanizeReport(last).replaced.length ? last : null; const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null; return (
    @@ -701,6 +698,8 @@ export function ResultLine({ { label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null }, { label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null }, { label: "汇总", value: report?.summary ?? null, wide: true, mono: false }, + // 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里 + { label: "原文", value: rawReport, wide: true, mono: false }, { label: "会话 ID", value: run.id, wide: true }, { label: "原始错误", value: run.error ? JSON.stringify(run.error) : null, wide: true }, ]} diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index 9a4a0cee..c761d35c 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -42,7 +42,7 @@ import { import { RunPlan } from "./agent-interaction.tsx"; import { shortGoalTitle } from "./agent-store.ts"; import { SubagentTree } from "./agent-subagent-tree.tsx"; -import { stepProgress } from "./conversation-model.ts"; +import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts"; import { NEW_RUN_COMMANDS, PROPOSE_FIRST, @@ -140,6 +140,47 @@ export function GoalComposer({ * 顶部概况条:一行「步骤 4/5 · 当前:核对下载任务」;展开看步骤清单、子会话和技术信息(模型、预算、用量、ID)。 * 不 sticky、不压在会话上;没有步骤和子会话时只在运行中显示状态。 */ +/** + * 会话统计(AG-4):概况条上方恒定一行小字——工具调用、MCP、已用 token、距上限、费用; + * 运行中随事件流实时更新,结束后固定为快照里的最终值。 + */ +export function RunStatsLine({ + snapshot, + live, + className, +}: { + snapshot: RunSnapshot; + live: AgentStreamState; + className?: string; +}) { + const r = snapshot.run; + const u = { ...r.usage, ...(live.usage ?? {}) }; + const n = callCounts(live.items.flatMap((i) => (i.kind === "tool" ? [i.call] : []))); + const parts = sessionStatsParts( + { + toolCalls: Math.max(r.toolCallCount - n.mcp, n.tools), + mcpCalls: n.mcp, + totalTokens: u.totalTokens ?? null, + maxTokens: r.budget.maxTokens ?? null, + costUsd: u.costUsd ?? null, + }, + { tokens: formatTokens, usd: formatUsd }, + ); + return ( +
    + {/* 窄屏折行,不截断(费用在最后) */} +

    + {parts.map((t, i) => ( + + {i ? "· " : ""} + {t} + + ))} +

    +
    + ); +} + export function RunOverviewBar({ snapshot, status, @@ -338,6 +379,7 @@ export function AgentRunPanel({ ) : null} + { expect(contextLabel(null)).toBeNull(); }); }); + +describe("会话统计与本轮状态(AG-4)", () => { + const humanized = (): AgentRunEvent[] => { + seq = 0; + return [ + ev(0, { type: "run_started", goal: "检查站点", resumed: false }), + ...tool(1, "t1", "site.list", '{"items":[{"name":"憨憨"}]}'), + ev(3, { + type: "text_delta", + delta: "site.list 返回站点 4e71672f-0000-4000-8000-000000000000 正常,没有 invalid_input。", + }), + ev(9, { type: "run_completed", finalText: "", usage: { totalTokens: 1000, costUsd: 0.01 } }), + ]; + }; + it("正文里不再列「换掉的标识」;原始汇报只在结果行的技术信息「原文」里", () => { + const live = liveOf(humanized()); + const t = text(render(createElement(AgentConversation, { run: RUN, live }))); + expect(t).not.toContain("换掉的标识"); + expect(t).not.toContain("正文原文"); + expect(t).not.toContain("→ 列出站点"); + const result = render(createElement(ResultLine, { run: RUN, report: null, live })); + expect(text(result)).toContain("原文"); + expect(result).toContain("4e71672f-0000-4000-8000-000000000000"); + expect(result).not.toContain("换掉的标识"); + }); + it("结束后本轮调用次数并入进度行", () => { + const activity: ActivityItem[] = [ + { kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) }, + { kind: "tool", key: "c2", call: call({ toolCallId: "c2", capabilityId: "inventory.query" }) }, + ]; + const html = render(createElement(StepSummary, { activity, running: false })); + expect(text(html.slice(0, html.indexOf("")))).toContain("调用 2 次工具"); + }); + it("运行中:思考状态与本轮已调用次数放在一行", () => { + const events = humanized().slice(0, 3); + const live = { ...liveOf(events), finished: false }; + const t = text(render(createElement(AgentConversation, { run: { ...RUN, status: "running" }, live }))); + expect(t).toContain("思考中 · 已调用 1 次工具"); + }); + it("概况条上方的会话统计:工具调用、token(K)、剩余、费用", () => { + const live = liveOf(humanized()); + const snapshot = { + run: { ...RUN, toolCallCount: 1, budget: { maxTokens: 200_000 }, usage: { totalTokens: 45_200, costUsd: 0.03 } }, + report: null, + messages: [], + } as never; + const t = text(render(createElement(RunStatsLine, { snapshot, live }))); + expect(t).toContain("工具调用 1 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03"); + }); +}); diff --git a/apps/web/app/components/agent/conversation-model.ts b/apps/web/app/components/agent/conversation-model.ts index 1f697237..babd2d12 100644 --- a/apps/web/app/components/agent/conversation-model.ts +++ b/apps/web/app/components/agent/conversation-model.ts @@ -60,6 +60,10 @@ const cjkJoin = (a: string, b: string) => (/^\d/.test(b) ? `${a} ${b}` : `${a}${ const joinNouns = (xs: string[]) => xs.length <= 1 ? (xs[0] ?? "") : cjkJoin(`${xs.slice(0, -1).join("、")}和`, xs.at(-1) ?? ""); +/** agent.spawn 的结果是"已有同目标子会话"(输出 reused = true;摘要可能被截断,按字段文本判定) */ +export const reusedSpawn = (outputSummary: string | null | undefined): boolean => + !!outputSummary && /"reused"\s*:\s*true/.test(outputSummary); + /** * 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数, * 同一动作的对象合并,动作按第一次出现排序;被拒绝、失败和隐藏的调用不计入(失败的在进度行另计「N 项失败」, @@ -71,6 +75,8 @@ export function activitySummary(calls: Pick x.verb === a.verb); if (!g) { g = { verb: a.verb, bare: !!a.bare, nouns: [] }; @@ -298,3 +304,65 @@ export function activitySpan(activity: ActivityItem[]): number | null { export function isLongReport(text: string): boolean { return text.length > 600 || text.split("\n").length > 12; } + +// ── 调用计数与会话统计(AG-4) ── + +/** MCP 工具:外部 MCP 服务的工具名(`mcp:` / `mcp__` 前缀或 `域__动作`);内置能力不算 */ +export const isMcpTool = (id: string): boolean => + !(id in CAPABILITY_CATALOG) && (/^mcp(?::|__|\.)/i.test(id) || id.includes("__")); + +/** 已发起的调用数(被拒绝的没执行,不算;运行中的算) */ +export function callCounts(calls: Pick[]): { + tools: number; + mcp: number; +} { + let tools = 0; + let mcp = 0; + for (const c of calls) { + if (c.status === "denied" || authDenial(c)) continue; + if (isMcpTool(c.capabilityId)) mcp++; + else tools++; + } + return { tools, mcp }; +} + +/** 「已调用 3 次工具、1 次 MCP」(结束后的进度行用「调用」);都为 0 时空串 */ +export function callCountText(n: { tools: number; mcp: number }, verb = "已调用"): string { + const parts = [n.tools ? `${n.tools} 次工具` : "", n.mcp ? `${n.mcp} 次 MCP` : ""].filter(Boolean); + return parts.length ? `${verb} ${parts.join("、")}` : ""; +} + +/** + * 会话统计一行:「工具调用 12 次 · token 45K · 剩 155K(77%) · 费用 $0.03」。 + * MCP 只在有调用时写;没设 token 上限时不写剩余。数字用 K / M 简写(formatTokens 由调用方传入,保持纯函数)。 + */ +export function sessionStatsParts( + s: { + toolCalls: number; + mcpCalls: number; + totalTokens: number | null; + maxTokens: number | null; + costUsd: number | null; + }, + fmt: { tokens(n: number): string; usd(n: number): string }, +): string[] { + const out = [`工具调用 ${s.toolCalls} 次`]; + if (s.mcpCalls) out.push(`MCP ${s.mcpCalls} 次`); + const used = s.totalTokens ?? 0; + out.push(`token ${fmt.tokens(used)}`); + if (s.maxTokens && s.maxTokens > 0) { + const left = Math.max(0, s.maxTokens - used); + out.push(`剩 ${fmt.tokens(left)}(${Math.round((left / s.maxTokens) * 100)}%)`); + } + out.push(`费用 ${fmt.usd(s.costUsd ?? 0)}`); + return out; +} + +/** 最后一段结论正文(汇报原文);没有时 null */ +export function lastReportText(items: readonly StreamItem[]): string | null { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i]; + if (it?.kind === "text" && it.text.trim()) return it.text; + } + return null; +} diff --git a/apps/web/app/components/agent/report-humanize.test.ts b/apps/web/app/components/agent/report-humanize.test.ts index 28a58db6..255833c6 100644 --- a/apps/web/app/components/agent/report-humanize.test.ts +++ b/apps/web/app/components/agent/report-humanize.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { activitySummary, stepProgress } from "./conversation-model.ts"; +import { + activitySummary, + callCounts, + callCountText, + isMcpTool, + lastReportText, + sessionStatsParts, + stepProgress, +} from "./conversation-model.ts"; import { collectNames, humanizeReport } from "./report-humanize.ts"; import type { StreamItem, ToolCallView } from "./use-agent-stream.ts"; @@ -94,6 +102,20 @@ describe("activitySummary(系统事实计数)", () => { ]; expect(activitySummary(calls)).toBe("派出了 2 个子会话"); }); + + it("同目标去重返回已有子会话的调用不计入(AG-4)", () => { + const calls = [ + { capabilityId: "agent.spawn", status: "succeeded" as const, outputSummary: '{"runId":"a"}' }, + { + capabilityId: "agent.spawn", + status: "succeeded" as const, + outputSummary: '{"reused":true,"message":"已有同目标子会话,仍在运行,未新建","runId":"a"…', + }, + ]; + expect(activitySummary(calls)).toBe("派出了 1 个子会话"); + // 只剩去重调用时不写派出 + expect(activitySummary(calls.slice(1))).toBe(""); + }); }); describe("stepProgress(步骤口径)", () => { @@ -111,3 +133,46 @@ describe("stepProgress(步骤口径)", () => { expect(stepProgress([s("done"), s("done")]).status).toBe("全部完成"); }); }); + +describe("调用计数与会话统计(AG-4)", () => { + const call = (capabilityId: string, status: "running" | "succeeded" | "failed" | "denied" = "succeeded") => ({ + capabilityId, + status, + outputSummary: status === "failed" ? '{"error":{"code":"unauthorized","message":"需要审批"}}' : "{}", + }); + it("内置能力算工具,MCP 工具单独计;被拒绝的不算,运行中的算", () => { + expect(isMcpTool("site.list")).toBe(false); + expect(isMcpTool("mcp__github__search")).toBe(true); + expect(isMcpTool("files__read")).toBe(true); + const n = callCounts([ + call("site.list"), + call("release.search", "running"), + call("download.submit", "denied"), + call("download.submit", "failed"), + call("mcp__fetch__get"), + ]); + expect(n).toEqual({ tools: 2, mcp: 1 }); + expect(callCountText(n)).toBe("已调用 2 次工具、1 次 MCP"); + expect(callCountText({ tools: 3, mcp: 0 }, "调用")).toBe("调用 3 次工具"); + expect(callCountText({ tools: 0, mcp: 0 })).toBe(""); + }); + it("会话统计一行:token 用 K 简写,有上限时写剩余与百分比,MCP 为 0 不写", () => { + const fmt = { tokens: (n: number) => `${Math.round(n / 100) / 10}K`, usd: (n: number) => `$${n.toFixed(2)}` }; + expect( + sessionStatsParts({ toolCalls: 12, mcpCalls: 0, totalTokens: 45_000, maxTokens: 200_000, costUsd: 0.03 }, fmt), + ).toEqual(["工具调用 12 次", "token 45K", "剩 155K(78%)", "费用 $0.03"]); + expect( + sessionStatsParts({ toolCalls: 1, mcpCalls: 2, totalTokens: null, maxTokens: null, costUsd: null }, fmt), + ).toEqual(["工具调用 1 次", "MCP 2 次", "token 0K", "费用 $0.00"]); + }); + it("汇报原文取最后一段正文", () => { + expect( + lastReportText([ + { kind: "text", key: "t1", text: "先查一下", at: "" }, + { kind: "thinking", key: "th", text: "…", at: "" }, + { kind: "text", key: "t2", text: "结论", at: "" }, + ]), + ).toBe("结论"); + expect(lastReportText([])).toBeNull(); + }); +}); From 1fcfcac885eda8bc836bdbbf3516ab2a20156b0d Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 18:05:12 +0800 Subject: [PATCH 5/6] feat(browser): keep sessions alive during desktop, takeover and agent use (AG-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit camofox reaps a tab after TAB_INACTIVITY_MS (5 min) without API tool calls; desktop-mode input goes straight to Selkies and screenshots don't count, so tabs disappeared under the user during takeover. - BrowserKeepalive (in-process): sessions held by an open desktop WebSocket (ref-counted), a human takeover lease, or a running/queued agent run that used browser.* get a keepalive every BROWSER_KEEPALIVE_MS (default 120s, 0 = off). Holds are re-checked each round; release/lease expiry/agent end/ session close stop it and camofox reaps as usual. - Keepalive call: camofox 1.17 has no keepalive/touch endpoint and /stats does not bump toolCalls; use GET /tabs/{id}/downloads without includeData/consume (read-only metadata). Goes through BrowserService.run so restarts or reaped tabs self-heal via BR-3 reconnect; no lastAutomationAt, no control needed. - Runbook browser.md §12, deploy env table, compose env; tests against the testkit mock camofox. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/config.ts | 5 + .../__tests__/browser-keepalive.test.ts | 183 ++++++++++++++++++ .../src/modules/browser/capabilities.ts | 22 ++- apps/server/src/modules/browser/keepalive.ts | 165 ++++++++++++++++ apps/server/src/modules/browser/service.ts | 80 +++++++- apps/server/src/modules/desktop/desktop.ts | 7 + apps/server/src/modules/desktop/index.ts | 3 + docker-compose.yml | 2 + docker/compose.env.example | 2 + docs/runbook/browser.md | 39 +++- docs/runbook/deploy.md | 1 + .../adapters/src/browser/camofox/client.ts | 18 ++ packages/adapters/src/browser/provider.ts | 8 + 13 files changed, 523 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts create mode 100644 apps/server/src/modules/browser/keepalive.ts diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 17ffd104..23d1016d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -87,6 +87,11 @@ export const ConfigSchema = z.object({ BROWSER_PROVIDER_URL: z.preprocess(emptyToUndef, z.string().url().optional()), /** 该服务的访问凭据(camofox CAMOFOX_ACCESS_KEY);只在进程内使用,不入库 */ BROWSER_ACCESS_KEY: z.preprocess(emptyToUndef, z.string().min(1).optional()), + /** + * 浏览器会话保活间隔(毫秒,AG-4):桌面接管 / 人工接管 / 智能体正在用的会话每隔这么久做一次只读调用, + * 避免 camofox 空闲 5 分钟回收标签页;0 = 关闭。要比 camofox 的 TAB_INACTIVITY_MS 短。 + */ + BROWSER_KEEPALIVE_MS: z.preprocess(emptyToUndef, z.coerce.number().int().min(0).default(120_000)), /** false = 停用 camofox-env(容器可照常运行,界面标"浏览器不可用") */ BROWSER_ENABLED: z.preprocess( emptyToUndef, diff --git a/apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts b/apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts new file mode 100644 index 00000000..61c92d4b --- /dev/null +++ b/apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { browserServiceFor } from "../index.ts"; +import { BrowserKeepalive } from "../keepalive.ts"; +import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts"; + +// AG-4 浏览器会话保活(对 testkit mock camofox):桌面连接 / 人工接管 / 智能体会话正在用的会话定期做一次 +// GET /tabs/{id}/downloads(不带 includeData / consume);用完不再保活;保活时标签页已被回收 → BR-3 自愈重连。 + +describe("BrowserKeepalive(登记与复查)", () => { + const fake = () => { + const calls: string[] = []; + const alive = new Set(["s1", "s2"]); + return { + calls, + alive, + target: { + keepAlive: async (id: string) => { + calls.push(id); + return alive.has(id); + }, + }, + }; + }; + + it("引用计数:同一会话多个桌面连接,全部断开才停;撤销可重复调用", async () => { + const f = fake(); + const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 }); + const a = k.hold("s1", "desktop", "desktop"); + const b = k.hold("s1", "desktop", "desktop"); + await k.tick(); + expect(f.calls).toEqual(["s1"]); + a(); + a(); + expect(k.sessions()).toEqual([{ sessionId: "s1", reasons: ["desktop"] }]); + b(); + expect(k.sessions()).toEqual([]); + await k.tick(); + expect(f.calls).toEqual(["s1"]); + }); + + it("复查不通过的登记撤销;会话已关闭(keepAlive 返回 false)整条撤销;ensure 不加引用计数", async () => { + const f = fake(); + const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 }); + let running = true; + k.ensure("s1", "agent:r1", "agent", async () => running); + k.ensure("s1", "agent:r1", "agent", async () => running); + k.hold("s2", "desktop", "desktop"); + await k.tick(); + expect(f.calls.sort()).toEqual(["s1", "s2"]); + running = false; + f.alive.delete("s2"); + await k.tick(); + expect(f.calls).toHaveLength(3); // s1 复查不通过不再保活;s2 保活一次后发现已关闭 + expect(k.sessions()).toEqual([]); + }); + + it("保活失败只记日志,下一轮继续;intervalMs = 0 时不登记", async () => { + const logs: string[] = []; + let fail = true; + const k = new BrowserKeepalive( + { + keepAlive: async () => { + if (fail) throw new Error("浏览器服务不可用"); + return true; + }, + }, + { intervalMs: 60_000, log: (_l, msg, extra) => logs.push(`${msg} ${String(extra?.err)}`) }, + ); + k.hold("s1", "takeover", "takeover"); + await k.tick(); + expect(logs).toEqual(["浏览器会话保活失败 浏览器服务不可用"]); + fail = false; + await k.tick(); + expect(k.sessions()).toHaveLength(1); + k.stop(); + + const off = new BrowserKeepalive(fake().target, { intervalMs: 0 }); + off.hold("s1", "desktop", "desktop"); + expect(off.sessions()).toEqual([]); + }); +}); + +const up = await pgAvailable(); + +describe.skipIf(!up)("AG-4 浏览器会话保活(mock camofox)", () => { + let h: BrowserHarness; + const svc = () => browserServiceFor(h.ctx)!; + const open = async (path: string) => + (await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } })) + .body.data as Loose; + const pings = (tabId: string) => + h.mock.callsTo(`/tabs/${encodeURIComponent(tabId)}/downloads`).filter((c) => c.method === "GET"); + const view = async (id: string) => (await h.req(`/browser/sessions/${id}`)).body.data as Loose; + + beforeAll(async () => { + h = await createBrowserHarness("ag4ka"); + await h.grant("browser.navigate"); + }, 120_000); + afterAll(async () => { + svc()?.keepalive.stop(); + await h?.close(); + }); + + it("人工接管期间保活:只读调用、不记自动化活动;交还后停止", async () => { + const s = await open("/torrents.php"); + const before = (await view(s.id)).lastAutomationAt; + const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } }); + expect(acq.status).toBe(200); + expect(svc().keepalive.sessions()).toEqual([{ sessionId: s.id, reasons: ["takeover"] }]); + + await svc().keepalive.tick(); + const p = pings(s.currentTabId); + expect(p).toHaveLength(1); + // 不带 includeData / consume:不读下载内容、不清下载记录 + expect(p[0]!.query).toEqual({ userId: s.profileKey }); + const v = await view(s.id); + expect(v.controller).toBe("human"); + expect(v.lastAutomationAt).toBe(before); + + await h.req(`/browser/sessions/${s.id}/control/release`, { body: { to: "automation", reason: "done" } }); + expect(svc().keepalive.sessions()).toEqual([]); + await svc().keepalive.tick(); + expect(pings(s.currentTabId)).toHaveLength(1); + await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" }); + }); + + it("桌面连接期间标签页被空闲回收 → 保活时按 BR-3 自愈重连回到原地址;断开后停止", async () => { + const s = await open("/details.php?id=42"); + const release = svc().keepalive.hold(s.id, "desktop", "desktop"); + // 上游空闲回收了标签页 + h.mock.tabs.delete(s.currentTabId); + await svc().keepalive.tick(); + const v = await view(s.id); + expect(v.currentTabId).not.toBe(s.currentTabId); + // 回到会话记录的地址(测试账号没有登录态,mock 站点跳登录页并带 returnto) + expect(decodeURIComponent(v.currentUrl)).toContain("/details.php?id=42"); + expect(v.reconnect).toMatchObject({ status: "ok", trigger: "tab_gone" }); + // 新标签页上重试了保活 + expect(pings(v.currentTabId)).toHaveLength(1); + + release(); + expect(svc().keepalive.sessions()).toEqual([]); + await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" }); + }); + + it("智能体会话在用:运行中保活;会话结束后停止;浏览器会话关闭也停止", async () => { + const run = await h.db + .insertInto("agent_run") + .values({ + trigger: "user", + goal: "看看种子列表", + status: "running", + mode: "execute", + scope: JSON.stringify({}), + budget: JSON.stringify({}), + created_by: "user:admin", + }) + .returning("id") + .executeTakeFirstOrThrow(); + const nav = (await h.registry.invoke( + "browser.navigate", + { siteId: h.siteId, url: `${ORIGIN}/torrents.php` }, + { ...h.agentCtx(), agentRunId: run.id }, + )) as Loose; + const sid = nav.sessionId as string; + expect(svc().keepalive.sessions()).toEqual([{ sessionId: sid, reasons: ["agent"] }]); + const tab = (await view(sid)).currentTabId as string; + await svc().keepalive.tick(); + expect(pings(tab)).toHaveLength(1); + + await h.db.updateTable("agent_run").set({ status: "succeeded" }).where("id", "=", run.id).execute(); + await svc().keepalive.tick(); + expect(pings(tab)).toHaveLength(1); + expect(svc().keepalive.sessions()).toEqual([]); + + // 再用一次(新会话在跑)→ 登记;关闭浏览器会话 → 撤销 + await h.db.updateTable("agent_run").set({ status: "running" }).where("id", "=", run.id).execute(); + await h.registry.invoke("browser.snapshot", { sessionId: sid }, { ...h.agentCtx(), agentRunId: run.id }); + expect(svc().keepalive.sessions()).toHaveLength(1); + await h.req(`/browser/sessions/${sid}`, { method: "DELETE" }); + expect(svc().keepalive.sessions()).toEqual([]); + }); +}); diff --git a/apps/server/src/modules/browser/capabilities.ts b/apps/server/src/modules/browser/capabilities.ts index d2aca8ac..30216b46 100644 --- a/apps/server/src/modules/browser/capabilities.ts +++ b/apps/server/src/modules/browser/capabilities.ts @@ -27,7 +27,12 @@ async function guard(fn: () => Promise): Promise { } } -const agent = (actorId: string): Actor => ({ kind: "automation", holder: actorId }); +/** 以自动化身份执行;智能体会话里调用时带上会话 ID(会话在跑期间保活这个浏览器会话,AG-4) */ +const agent = (ctx: HandlerContext): Actor => ({ + kind: "automation", + holder: ctx.actorId, + ...(ctx.agentRunId ? { agentRunId: ctx.agentRunId } : {}), +}); /** 敏感截图(登录页 / 密码框)交给模型的授权:有效的 browser.sensitive_screenshot(或 *)持续授权 */ export const SENSITIVE_SCREENSHOT_GRANT = "browser.sensitive_screenshot"; @@ -61,7 +66,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( const id = await resolveSession(input); const snap = await svc().run( id, - agent(ctx.actorId), + agent(ctx), (p, ref) => p.snapshot(ref, input.offset !== undefined ? { offset: input.offset } : {}), { write: false, patch: () => ({ needsSnapshot: false }), ...(input.tabId ? { tabId: input.tabId } : {}) }, ); @@ -91,6 +96,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( url: input.url, })); if (!live) { + if (ctx.agentRunId) svc().holdForAgent(id, ctx.agentRunId); const v = await svc().view(id); const info = v.currentUrl ?? ""; return { @@ -107,7 +113,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( const sid = id; const nav = await svc().run( sid, - agent(ctx.actorId), + agent(ctx), async (p, ref, x) => { await svc().limited(x.site); // 标签页全被上游回收(重启 / 空闲超时,已对账为 lost):在同一会话(同一 profile)里开新标签页,不重放旧引用 @@ -143,7 +149,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( if (needTarget && !input.target) throw new BrowserServiceError("bad_request", `${input.action} 需要 target`); await s.run( input.sessionId, - agent(ctx.actorId), + agent(ctx), async (p, ref, x) => { switch (input.action) { case "click": @@ -183,7 +189,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( const s = svc(); const artifact = await s.run( input.sessionId, - agent(ctx.actorId), + agent(ctx), async (p, ref) => { if (input.target) await p.click(ref, input.target); return p.waitForDownload(ref, { timeoutMs: input.timeoutMs, maxBytes: 10 * 1024 * 1024 }); @@ -221,7 +227,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( registry.register(capabilitySpec("browser.screenshot"), (input, ctx) => guard(async () => { const s = svc(); - return s.capture(input.sessionId, agent(ctx.actorId), { + return s.capture(input.sessionId, agent(ctx), { ...(input.tabId ? { tabId: input.tabId } : {}), wantImage: wantImage(ctx), sensitiveAllowed: await sensitiveAllowed(s, input.allowSensitive), @@ -238,7 +244,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( const s = svc(); const page = point ? s.toPage(input.sessionId, point.x, point.y) : null; const at = page ? { x: page.pageX, y: page.pageY } : null; - const shot = await s.visionAct(input.sessionId, agent(ctx.actorId), (p, ref) => fn(p, ref, at), { + const shot = await s.visionAct(input.sessionId, agent(ctx), (p, ref) => fn(p, ref, at), { wantImage: wantImage(ctx), sensitiveAllowed: await sensitiveAllowed(s, input.allowSensitive), }); @@ -280,7 +286,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: ( guard(async () => { const s = svc(); await new Promise((r) => setTimeout(r, input.ms)); - const shot = await s.capture(input.sessionId, agent(ctx.actorId), { + const shot = await s.capture(input.sessionId, agent(ctx), { wantImage: wantImage(ctx), sensitiveAllowed: await sensitiveAllowed(s, input.allowSensitive), }); diff --git a/apps/server/src/modules/browser/keepalive.ts b/apps/server/src/modules/browser/keepalive.ts new file mode 100644 index 00000000..80368f3c --- /dev/null +++ b/apps/server/src/modules/browser/keepalive.ts @@ -0,0 +1,165 @@ +// AG-4 浏览器会话保活:camofox 的标签页空闲 TAB_INACTIVITY_MS(缺省 5 分钟)没有 API 调用就回收, +// 会话空闲 SESSION_TIMEOUT_MS 回收,全部会话没了之后 BROWSER_IDLE_TIMEOUT_MS 退出浏览器。 +// 桌面模式里人的键鼠直接进 Selkies,不经 API;原站视图的截图轮询也不算标签页活动——接管期间页面会被关掉。 +// 这里在"有人 / 有智能体正在用"的会话上每隔 intervalMs 做一次最轻的只读调用(BrowserProvider.keepAlive), +// 用完(桌面断开、交还 / 接管结束、智能体会话结束、会话关闭)就不再保活,照常由上游空闲回收。 +// 保活调用走 BrowserService.run:浏览器重启过 / 标签页已被回收时按 BR-3 自愈重连。 +// 保活登记只在进程内:api 进程登记桌面与接管,跑智能体的进程登记智能体会话,各自保活自己的。 + +/** 缺省保活间隔:比上游 5 分钟空闲回收短一半还多 */ +export const BROWSER_KEEPALIVE_MS = 120_000; +/** 智能体会话最后一次用这个浏览器会话之后,最多再保活多久(兜底:会话状态读不到时也会停) */ +export const AGENT_KEEPALIVE_MAX_IDLE_MS = 30 * 60_000; + +export type KeepaliveReason = "desktop" | "takeover" | "agent"; + +interface Hold { + reason: KeepaliveReason; + /** 引用计数(同一桌面会话开了多个连接) */ + refs: number; + /** 每次保活前复查;false = 不再需要 */ + check?: () => Promise; +} + +export interface KeepaliveTarget { + /** 对会话做一次保活;会话已关闭返回 false */ + keepAlive(sessionId: string): Promise; +} + +export interface KeepaliveOptions { + /** 0 = 关闭保活 */ + intervalMs: number; + log?: (level: "info" | "warn", msg: string, extra?: Record) => void; +} + +export class BrowserKeepalive { + /** sessionId → (登记键 → 登记) */ + private readonly holds = new Map>(); + private timer: ReturnType | undefined; + private ticking: Promise | null = null; + + constructor( + private readonly target: KeepaliveTarget, + private readonly opts: KeepaliveOptions, + ) {} + + get enabled(): boolean { + return this.opts.intervalMs > 0; + } + + /** 当前在保活的会话(测试 / 状态页用) */ + sessions(): { sessionId: string; reasons: KeepaliveReason[] }[] { + return [...this.holds].map(([sessionId, m]) => ({ + sessionId, + reasons: [...new Set([...m.values()].map((h) => h.reason))], + })); + } + + /** + * 登记一个使用方;返回的函数撤销这一次登记(可重复调用)。同一 key 多次登记按引用计数。 + * check 在每次保活前复查,返回 false 时整条登记撤销。 + */ + hold(sessionId: string, key: string, reason: KeepaliveReason, check?: () => Promise): () => void { + if (!this.enabled) return () => {}; + let m = this.holds.get(sessionId); + if (!m) { + m = new Map(); + this.holds.set(sessionId, m); + } + const cur = m.get(key); + if (cur) { + cur.refs++; + if (check) cur.check = check; + } else m.set(key, { reason, refs: 1, ...(check ? { check } : {}) }); + this.ensureTimer(); + let released = false; + return () => { + if (released) return; + released = true; + const h = this.holds.get(sessionId)?.get(key); + if (!h) return; + if (--h.refs <= 0) this.unhold(sessionId, key); + }; + } + + /** 没登记过才登记(不加引用计数);已登记时只换复查函数。用于"最近在用"类登记(智能体会话每次调用都会续) */ + ensure(sessionId: string, key: string, reason: KeepaliveReason, check?: () => Promise): void { + const h = this.holds.get(sessionId)?.get(key); + if (h) { + if (check) h.check = check; + return; + } + this.hold(sessionId, key, reason, check); + } + + /** 撤销某个 key 的登记(不管引用计数) */ + unhold(sessionId: string, key: string): void { + const m = this.holds.get(sessionId); + if (!m) return; + m.delete(key); + if (!m.size) this.holds.delete(sessionId); + this.maybeStop(); + } + + /** 会话关闭:撤销全部登记 */ + drop(sessionId: string): void { + if (this.holds.delete(sessionId)) this.maybeStop(); + } + + /** 一轮保活(定时器调用;测试直接调) */ + tick(): Promise { + if (this.ticking) return this.ticking; + this.ticking = this.runTick().finally(() => { + this.ticking = null; + }); + return this.ticking; + } + + stop(): void { + this.holds.clear(); + this.maybeStop(); + } + + private async runTick(): Promise { + for (const [sessionId, m] of [...this.holds]) { + for (const [key, h] of [...m]) { + if (!h.check) continue; + const ok = await h.check().catch(() => true); + if (!ok) m.delete(key); + } + if (!m.size) { + this.holds.delete(sessionId); + continue; + } + try { + const alive = await this.target.keepAlive(sessionId); + if (!alive) this.holds.delete(sessionId); + } catch (e) { + // 自愈重连也失败(浏览器服务不可用等):记日志,下一轮再试;会话本身的状态由 run() 写好(needsReconnect / reconnect.failed) + this.opts.log?.("warn", "浏览器会话保活失败", { + sessionId, + reasons: [...new Set([...m.values()].map((x) => x.reason))], + err: e instanceof Error ? e.message : String(e), + }); + } + } + this.maybeStop(); + } + + private ensureTimer(): void { + if (this.timer !== undefined || !this.enabled) return; + this.timer = setInterval(() => void this.tick(), this.opts.intervalMs); + // 不拖住进程退出(测试、优雅停机) + try { + Deno.unrefTimer(this.timer as unknown as number); + } catch { + /* 非 Deno 运行时 */ + } + } + + private maybeStop(): void { + if (this.holds.size || this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } +} diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index 8fdda9b3..bc5b6ac4 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -59,12 +59,18 @@ import { } from "../sources-identity/service/credentials.ts"; import { registerBrowserIdentityReader } from "../sources-identity/service/user-agent.ts"; import type { ServerContext } from "../types.ts"; +import { AGENT_KEEPALIVE_MAX_IDLE_MS, BROWSER_KEEPALIVE_MS, BrowserKeepalive } from "./keepalive.ts"; // C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、 // 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。 // 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。 -export type Actor = { kind: "automation" | "human"; holder: string }; +export type Actor = { + kind: "automation" | "human"; + holder: string; + /** 智能体会话发起的动作(AG-4):会话还在跑期间保活这个浏览器会话 */ + agentRunId?: string; +}; export interface BrowserModuleOptions { /** camofox 请求用的 fetch(测试指向 mock) */ @@ -79,6 +85,8 @@ export interface BrowserModuleOptions { evidenceDir?: string; /** 坐标动作之后等页面稳定再截图的毫秒数;缺省 400 */ settleMs?: number; + /** 保活间隔(AG-4);缺省 BROWSER_KEEPALIVE_MS 配置(120 秒),0 = 关闭 */ + keepaliveMs?: number; /** 测试替换 provider 实现 */ providerFactory?: (row: BrowserProviderRow, deps: ProviderDeps) => BrowserProvider; } @@ -194,12 +202,21 @@ export class BrowserService { private readonly providers = new Map(); private envSync: Promise | undefined; readonly now: () => Date; + /** AG-4 保活:桌面 / 人工接管 / 智能体正在用的会话定期做一次只读调用 */ + readonly keepalive: BrowserKeepalive; constructor( readonly ctx: ServerContext, readonly opts: BrowserModuleOptions = {}, ) { this.now = opts.now ?? (() => new Date()); + this.keepalive = new BrowserKeepalive( + { keepAlive: (id) => this.keepAlive(id) }, + { + intervalMs: opts.keepaliveMs ?? ctx.config?.BROWSER_KEEPALIVE_MS ?? BROWSER_KEEPALIVE_MS, + log: (level, msg, extra) => ctx.logger[level]({ component: "browser", ...extra }, msg), + }, + ); // refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块) registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId)); // 站点请求 UA 跟随浏览器固定身份(BR-4):sources-identity 经钩子取 @@ -1081,6 +1098,7 @@ export class BrowserService { } async close(id: string, reason = "closed"): Promise { + this.keepalive.drop(id); const row = await this.sessionRow(id); if (row.controller === "closed") return; const prow = await this.ctx.db @@ -1235,9 +1253,38 @@ export class BrowserService { .execute(); return l; }); + // 接管期间人的操作不经上游 API(桌面模式),由 app 保活;交还 / 租约过期 / 会话关闭后停 + this.keepalive.ensure(id, "takeover", "takeover", () => this.humanHolding(id)); return toLease(lease); } + /** 会话仍在人工接管中(租约有效) */ + private async humanHolding(id: string): Promise { + const row = await this.sessionRow(id).catch(() => null); + if (row?.controller !== "human") return false; + const lease = await this.activeLease(id); + return lease?.holder_kind === "human" && new Date(lease.expires_at).getTime() > this.now().getTime(); + } + + /** + * 保活一次(AG-4):对当前标签页做最轻的只读调用。不要求控制权、不记自动化活动;浏览器重启过 / 标签页已被回收时 + * 按 BR-3 自愈重连(注入 Cookie、回到原地址)。会话已关闭返回 false;上游不支持保活时只做实例检查与对账。 + */ + async keepAlive(id: string): Promise { + const row = await this.sessionRow(id).catch(() => null); + if (!row || row.controller === "closed") return false; + await this.run( + id, + { kind: "automation", holder: "system:keepalive" }, + async (p, ref) => { + if (p.keepAlive) await p.keepAlive(ref); + else await p.reconcileTabs(id); + }, + { write: false, persist: false, background: true }, + ); + return true; + } + /** 交还:回到自动化(或留在等待处理);Agent 恢复前必须重新读取页面,不复用旧引用、不重放旧点击 */ async release( id: string, @@ -1248,6 +1295,7 @@ export class BrowserService { if (lease?.holder_kind !== "human" || (opts.leaseId && lease.id !== opts.leaseId)) throw new BrowserServiceError("conflict", "没有可交还的人工接管"); const at = this.now(); + this.keepalive.unhold(id, "takeover"); const { provider } = await this.loaded(row); provider.setController(id, opts.to); const rs = obj(row.recovery_state) as RecoveryState; @@ -1296,7 +1344,14 @@ export class BrowserService { ref: TabRef, ctx: { row: BrowserSessionRow; site: SiteRow; budget: NavigationBudget }, ) => Promise, - opts: { tabId?: string; write?: boolean; persist?: boolean; patch?: (out: T) => RecoveryState } = {}, + opts: { + tabId?: string; + write?: boolean; + persist?: boolean; + patch?: (out: T) => RecoveryState; + /** 后台维护调用(保活):不记自动化活动 */ + background?: boolean; + } = {}, ): Promise { let row = await this.expireLeases(await this.sessionRow(id)); if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭"); @@ -1366,7 +1421,9 @@ export class BrowserService { throw fromBrowserError(e2); } } - const automation: RecoveryState = actor.kind === "automation" ? { lastAutomationAt: this.now().toISOString() } : {}; + const automation: RecoveryState = + actor.kind === "automation" && !opts.background ? { lastAutomationAt: this.now().toISOString() } : {}; + if (actor.agentRunId) this.holdForAgent(id, actor.agentRunId); if (opts.persist !== false) await this.persist(id, loaded.provider, { ...(opts.patch?.(out) ?? {}), ...automation }); else if (automation.lastAutomationAt) await this.patchRecovery(await this.sessionRow(id), automation); @@ -1374,6 +1431,23 @@ export class BrowserService { return out; } + /** 智能体会话最后一次用各浏览器会话的时间(保活复查用) */ + private readonly agentUse = new Map(); + + /** 智能体会话在用这个浏览器会话:会话还在排队 / 运行期间保活(最后一次使用后最多 AGENT_KEEPALIVE_MAX_IDLE_MS) */ + holdForAgent(sessionId: string, runId: string): void { + const key = `agent:${runId}`; + this.agentUse.set(`${sessionId}|${runId}`, Date.now()); + this.keepalive.ensure(sessionId, key, "agent", async () => { + const last = this.agentUse.get(`${sessionId}|${runId}`) ?? 0; + const r = await this.ctx.db.selectFrom("agent_run").select("status").where("id", "=", runId).executeTakeFirst(); + const ok = + !!r && (r.status === "running" || r.status === "queued") && Date.now() - last < AGENT_KEEPALIVE_MAX_IDLE_MS; + if (!ok) this.agentUse.delete(`${sessionId}|${runId}`); + return ok; + }); + } + // ---------- BR-3 浏览器服务重启后自愈 ---------- /** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */ diff --git a/apps/server/src/modules/desktop/desktop.ts b/apps/server/src/modules/desktop/desktop.ts index 621f2b47..40bbdfbc 100644 --- a/apps/server/src/modules/desktop/desktop.ts +++ b/apps/server/src/modules/desktop/desktop.ts @@ -64,6 +64,8 @@ export interface DesktopDeps { fetch?: typeof fetch; /** 角色复查间隔 */ roleCheckMs?: number; + /** 桌面连接建立(AG-4):返回连接断开时调用的函数。用于连接期间保活浏览器会话 */ + onConnect?: (sessionId: string) => () => void; /** 可用性探测缓存 */ probeTtlMs?: number; log?: (msg: string, extra?: Record) => void; @@ -256,6 +258,7 @@ export function registerDesktopRoutes(app: ModuleApp, deps: DesktopDeps): { clos if (!sameOrigin(c.req.raw)) throw new ApiException("forbidden", "桌面连接必须来自本站页面"); const s = await requireSession(deps, sessionId, holder); return bridgeWebSocket(c.req.raw, target.replace(/^http/, "ws"), s.role, { + ...(deps.onConnect ? { onOpen: () => deps.onConnect?.(sessionId) ?? (() => {}) } : {}), auth: (role) => basicAuth(passwords[role]), recheck: async () => { if (!(await deps.consoleSessionValid(sessionRowId))) return null; @@ -312,6 +315,8 @@ interface BridgeOpts { roleCheckMs: number; live: Set<() => void>; log(msg: string, extra?: Record): void; + /** 连接已升级:返回断开时调用的函数(保活登记) */ + onOpen?: () => () => void; } /** 浏览器 ⇄ app ⇄ Selkies 的 WebSocket 双向转发;角色变化时断开(客户端重连后按新角色认证) */ @@ -321,6 +326,7 @@ function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: Bri .map((s) => s.trim()) .filter(Boolean); const { socket: client, response } = Deno.upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {}); + const onClose = o.onOpen?.(); client.binaryType = "arraybuffer"; // Deno 的客户端 WebSocket 支持自定义请求头;不带 Origin(Selkies 对无 Origin 的非浏览器客户端放行) // (tsc 按 DOM 类型只认 protocols 参数;Deno 运行时接受 { headers, protocols }) @@ -336,6 +342,7 @@ function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: Bri closed = true; if (timer !== undefined) clearInterval(timer); o.live.delete(close); + onClose?.(); try { if (client.readyState <= WebSocket.OPEN) client.close(code, reason); } catch { diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts index a05deae4..2dbe9fd1 100644 --- a/apps/server/src/modules/desktop/index.ts +++ b/apps/server/src/modules/desktop/index.ts @@ -1,3 +1,4 @@ +import { browserServiceFor } from "../browser/index.ts"; import type { ServerContext, ServerModule } from "../types.ts"; import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts"; @@ -18,6 +19,8 @@ export function createDesktopModule(): ServerModule { }), accessKey: ctx.config?.BROWSER_ACCESS_KEY ?? null, ...dbSessionChecks(ctx.db), + // 桌面连接期间保活浏览器会话(AG-4):人在桌面里的操作不经 camofox API,不保活会被空闲 5 分钟回收 + onConnect: (sessionId) => browserServiceFor(ctx)?.keepalive.hold(sessionId, "desktop", "desktop") ?? (() => {}), log: (msg, extra) => ctx.logger.warn({ component: "desktop", ...extra }, msg), }); closeAll = r.closeAll; diff --git a/docker-compose.yml b/docker-compose.yml index 6829f3a2..ff3d2063 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -191,6 +191,8 @@ services: BROWSER_PROVIDER_URL: ${BROWSER_PROVIDER_URL:-http://camofox:9377} BROWSER_ACCESS_KEY: ${BROWSER_ACCESS_KEY:-${CAMOFOX_ACCESS_KEY:-}} BROWSER_ENABLED: ${BROWSER_ENABLED:-true} + # 桌面接管 / 人工接管 / 智能体正在用的会话每隔多久保活一次(camofox 标签页空闲 5 分钟回收);0 = 关闭 + BROWSER_KEEPALIVE_MS: ${BROWSER_KEEPALIVE_MS:-120000} # 原站视图桌面模式的上游(桌面镜像的 Selkies);缺省由 BROWSER_PROVIDER_URL 推出(端口换 3000) BROWSER_DESKTOP_URL: ${BROWSER_DESKTOP_URL:-} # 出站代理(HTTP_PROXY/HTTPS_PROXY)在 override 里给(见 docs/runbook/deploy.md「容器经宿主代理」)。 diff --git a/docker/compose.env.example b/docker/compose.env.example index d71061fd..6a71fcb6 100644 --- a/docker/compose.env.example +++ b/docker/compose.env.example @@ -50,6 +50,8 @@ CAMOFOX_ACCESS_KEY= # 完全不用浏览器:CAMOFOX_REPLICAS=0 且 BROWSER_ENABLED=false(界面标"浏览器不可用") # CAMOFOX_REPLICAS=1 # BROWSER_ENABLED=true +# 桌面接管 / 人工接管 / 智能体正在用的浏览器会话的保活间隔(毫秒,0 = 关闭;要比 camofox 标签页空闲回收 5 分钟短) +# BROWSER_KEEPALIVE_MS=120000 # camofox 调优(可选):会话空闲回收(回收时落盘登录状态)、浏览器空闲退出、最多会话数、内存上限 # CAMOFOX_SESSION_TIMEOUT_MS=1800000 # CAMOFOX_BROWSER_IDLE_TIMEOUT_MS=300000 diff --git a/docs/runbook/browser.md b/docs/runbook/browser.md index cedd7e87..67d080b6 100644 --- a/docs/runbook/browser.md +++ b/docs/runbook/browser.md @@ -342,7 +342,8 @@ docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./umt-root.cr - Firefox 工具栏里有 Playwright 的 "JUGGLER" 标识;用户可在画面里打开 `about:addons`、新标签页、输入任意网址——越界防护 (`policies.json`、`context.route` 白名单、交还时核对 URL,方案 §6)未做。 -- 接管期间 camofox 看不到 API 请求,`SESSION_TIMEOUT_MS`(缺省 30 分钟)到期会回收会话窗口;接管心跳(方案 §5.5 插件)未做。 +- 接管期间 camofox 看不到 API 请求:桌面连接期间与人工接管期间由 app 定期保活(§12,AG-4),不再被空闲回收; + 浏览器里的心跳插件(方案 §5.5)不做。 - 一个桌面承载所有站点会话:持有 A 站租约的人在画面里能看到 / 操作切到前台的其它窗口;目前同一时刻一般只有一个会话窗口。 - 真实输入法(Windows 微软拼音、macOS 日文)、Firefox / Safari 客户端、真实 PT 站点未测;容器内 fcitx5 只验证能启动,未验证组字。 - 画面坐标与 camofox 截图坐标不同(桌面含 56 px 工具栏,Camoufox 伪装了 `screenX` / `outerHeight`),Agent 视觉操作继续用 camofox 截图。 @@ -605,3 +606,39 @@ Camoufox 浏览器包的层都走构建缓存,**不会重新下载 Camoufox(约 `migration-browser-identity.test.ts`、`apps/server/src/modules/browser/__tests__/browser-identity.test.ts`(出口下发、身份存库)、 `packages/adapters/src/browser/umt-plugin.test.ts`(插件纯函数、适配器接口);真机:本地临时容器(`--network none`,只开容器内本地 页面)核验了 §11.1 表格与 §11.5 出口行为,未对真实站点发请求。 + +## 12. 会话保活(AG-4) + +camofox 按 **API 调用**判断活动,有三道空闲回收(`docker-compose.yml` 的 camofox 环境变量): + +| 回收 | 缺省 | 判据 | +| --- | --- | --- | +| 标签页 `TAB_INACTIVITY_MS` | 5 分钟 | 每 60 秒看一次标签页的 `toolCalls` 有没有变;没变且超过 5 分钟 → 关标签页,会话空了连会话一起关 | +| 会话 `SESSION_TIMEOUT_MS` | 30 分钟 | 会话 `lastAccess`(大部分 `/tabs/{id}/*` 接口都会刷新) | +| 浏览器 `BROWSER_IDLE_TIMEOUT_MS` | 5 分钟 | 一个会话都没有之后浏览器进程退出 | + +桌面模式里人的键鼠直接进 Selkies,原站视图截图(`GET /tabs/{id}/screenshot`)只刷新 `lastAccess`、不加 `toolCalls`—— +接管 5 分钟后标签页被关掉,桌面画面里的窗口消失。app 在"正在用"的会话上定期保活,代码 `modules/browser/keepalive.ts`、 +`BrowserService.keepAlive`、适配器 `keepAlive`。 + +**保活调用**:camofox 1.17 没有 keepalive / touch 接口;`GET /tabs/{id}/stats` 只刷新 `lastAccess`,挡不住标签页回收。 +用 `GET /tabs/{id}/downloads?userId=…`(不带 `includeData` / `consume`):只列下载元数据,不读页面、不改页面、不清下载记录, +会让 `toolCalls` 加一、刷新 `lastAccess`。只对当前标签页做,不要求控制权,不记 `lastAutomationAt`。 + +**什么时候保活**(进程内登记,各进程保活自己登记的): + +| 使用方 | 开始 | 停止 | +| --- | --- | --- | +| 桌面连接 | 桌面模式 WebSocket 建立(控制或只读都算,多个连接按引用计数) | 全部连接断开 | +| 人工接管 | `control/acquire` | 交还(`control/release`)、租约过期、会话关闭 | +| 智能体会话 | 智能体会话里的浏览器能力调用(`browser.*`,含 `browser.navigate` 新开会话) | 智能体会话不再是排队 / 运行中(结束、等待下载或等用户),或最后一次使用后 30 分钟 | + +每 `BROWSER_KEEPALIVE_MS`(缺省 120000 = 2 分钟,0 = 关闭;要比 `TAB_INACTIVITY_MS` 短)对登记的会话各做一次; +每轮先复查登记是否还有效,没有使用方的会话不再保活,照常由 camofox 空闲回收。会话关闭(`DELETE`)立即撤销全部登记。 + +**保活失败**:保活走与其它动作相同的 `run()` 路径——浏览器重启过(实例标识变了)或标签页已被回收(404 / 410、适配器里没有打开的标签页) +→ 按 §10 自愈重连(注入 Cookie、同一会话新开标签页回到原地址)后在新标签页上再保活一次;重连也失败(浏览器服务不可用)只记 warn 日志 +「浏览器会话保活失败」,会话照常标 `needsReconnect` / `reconnect.status = failed`,下一轮再试。 + +核验:`apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts`(登记 / 引用计数 / 复查;对 mock camofox:接管期间保活、 +不记自动化活动、交还后停;桌面连接期间标签页被回收 → 保活时自愈重连;智能体会话结束后停、会话关闭撤销)。真机未核验。 diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md index 9a95dbdf..16161ad2 100644 --- a/docs/runbook/deploy.md +++ b/docs/runbook/deploy.md @@ -134,6 +134,7 @@ docker compose logs app | grep 'setup token' | `BROWSER_PROVIDER_URL` | `http://camofox:9377` | app 登记的浏览器服务地址(`camofox-env`);接自有实例时改 | | `BROWSER_ACCESS_KEY` | `CAMOFOX_ACCESS_KEY` | 该服务的访问凭据,只在进程内使用,不入库 | | `BROWSER_ENABLED` | `true` | `false` = 停用 `camofox-env`,界面标"浏览器不可用" | +| `BROWSER_KEEPALIVE_MS` | `120000` | 桌面接管 / 人工接管 / 智能体正在用的浏览器会话的保活间隔,0 = 关闭;要比 camofox 标签页空闲回收(5 分钟)短(`docs/runbook/browser.md` §12) | | `CAMOFOX_REPLICAS` | `1` | `0` = 不起 camofox 容器 | | `CAMOFOX_SESSION_TIMEOUT_MS` / `CAMOFOX_BROWSER_IDLE_TIMEOUT_MS` | `1800000` / `300000` | 会话空闲回收(落盘登录状态)/ 浏览器空闲退出 | | `CAMOFOX_MAX_SESSIONS` / `CAMOFOX_MEM_LIMIT` | `8` / `2g` | 最多并发会话 / 容器内存上限 | diff --git a/packages/adapters/src/browser/camofox/client.ts b/packages/adapters/src/browser/camofox/client.ts index 5229f147..dcc0e7e4 100644 --- a/packages/adapters/src/browser/camofox/client.ts +++ b/packages/adapters/src/browser/camofox/client.ts @@ -811,6 +811,24 @@ export class CamofoxBrowserProvider implements BrowserProvider { }); } + /** + * AG-4 保活:camofox 1.17 没有 keepalive / touch 接口。`GET /tabs/{id}/stats` 只刷新会话的 lastAccess, + * 不算标签页活动(标签页回收看 toolCalls 有没有变化);最轻的会让 toolCalls 加一的只读调用是 + * `GET /tabs/{id}/downloads`(不带 includeData / consume:只列下载元数据,不读页面、不清下载记录)。 + */ + async keepAlive(ref: TabRef): Promise { + const s = this.state(ref.sessionId); + await this.withProfile(s, async () => { + const tab = this.tabOf(s, ref); + const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/downloads`; + const res = await this.request("GET", path, { + query: { userId: s.session.profileKey }, + timeoutMs: Math.min(this.cfg.timeoutMs ?? 30_000, 10_000), + }); + await res.body?.cancel(); + }); + } + // ---------- 页面 ---------- async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise { diff --git a/packages/adapters/src/browser/provider.ts b/packages/adapters/src/browser/provider.ts index b40f5b17..4734b743 100644 --- a/packages/adapters/src/browser/provider.ts +++ b/packages/adapters/src/browser/provider.ts @@ -93,6 +93,14 @@ export interface BrowserProvider { */ reopenSession?(sessionId: string, req: ReopenSessionRequest): Promise; + // ── AG-4 保活 ── + /** + * 对标签页做一次最轻的只读调用,让上游把它算作"有活动"(camofox 标签页空闲回收 TAB_INACTIVITY_MS 与会话空闲回收 + * SESSION_TIMEOUT_MS 都按 API 调用计;桌面模式里人的键鼠操作不经 API,不算活动)。不检查控制者、不改页面。 + * 标签页不在了报 not_found(调用方按 BR-3 自愈重连)。 + */ + keepAlive?(ref: TabRef): Promise; + // ── BR-4 浏览器 UA 与指纹统一、出口按站点 ── /** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */ identity?(): Promise; From f4a7fa863e48bbec050fcc3c1fcd5ffd13b09c45 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 18:05:12 +0800 Subject: [PATCH 6/6] docs(agent): AG-4 runbook and changelog (spawn dedupe, subagent language, session stats) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ docs/runbook/agent.md | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98243e5c..5bac7a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 变更(AG-4 智能体与浏览器零碎问题) +- 子会话系统提示与父会话一样要求思考、步骤说明和汇报都用中文(目标、工具结果是英文也一样)。 +- `agent.spawn` 同目标去重:同一父会话里同一目标的子会话还在运行或 10 分钟内已完成时不再新建,返回已有子会话与结果摘要(`reused: true`)。 +- 浏览器会话保活:桌面连接、人工接管、智能体会话正在用的会话每 2 分钟做一次只读调用(`BROWSER_KEEPALIVE_MS`,0 = 关闭),不再被 camofox 空闲 5 分钟回收;用完照常回收,保活时标签页已被回收按 BR-3 自愈重连。 +- 会话页:概况条上方恒定一行会话统计(工具调用、MCP、已用 token、距上限剩余、费用);本轮进行中显示「思考中 · 已调用 N 次工具」,结束后并入进度行;汇报兜底不再列「换掉的标识」,原始汇报只在技术信息「原文」里。 + ## [0.3.2] - 2026-09-30 ### 新增(BR-4 浏览器 UA 与指纹统一) diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index 9f894caa..e6f9509d 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -77,6 +77,8 @@ AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白 **预算提醒**:工具调用只剩 ≤2 次,或 token / 费用 / 时长用到 85% 时,运行时在工具结果末尾追加「【预算提醒】…停止探查,下一条回复直接用中文写小结」,系统提示要求模型见到后直接收尾。 **等待期间的用量**:会话挂起(审批 / 选方案 / 等子会话)时没有 `run_completed`,drive 退出前把运行时累计用量按字段取较大值写回 `agent_run.usage`,会话页和列表在等待期间照样显示已用 token / 费用。 + 会话页(AG-4):概况条上方恒定一行会话统计「工具调用 N 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03」(有 MCP 工具调用时加「MCP N 次」,没设 token 上限不写剩余;运行中随事件流更新);运行中本轮末尾一行「思考中 / 调用工具中 / 回答中 · 已调用 N 次工具、M 次 MCP」,本轮结束后次数并入进度行「调用 N 次工具」。内置智能体目前只调用本工具的能力,MCP 计数只对外部 MCP 工具(`mcp__…` / `域__动作` 形式的工具名)生效。汇报兜底(FIX-7)换掉的 ID / 能力名 / 原因码不再在正文下列映射表,原始汇报只放在结果行「技术信息」的「原文」里。 + 模型汇报在会话页按 Markdown 安全子集渲染(标题、列表、表格、引用、代码、粗体 / 斜体 / 链接;原文 HTML 按文字显示,链接只放行 http(s) 与站内路径),流式输出中仍按原文显示。 worker 崩溃后,新 worker 在引擎租约到期后(约 30s)接手,`drive` 步骤会从头重跑。已经完成的工具调用不会重放。副作用调用的结果模型会看到"结果未知",模型需要重新核对;同参数再调用一次,会直接拿到账本里的结果。 @@ -258,6 +260,8 @@ OpenRouter 换到的是永久 key,不刷新。 - 开关:`pi.subagents.enabled`(设置页「允许子代理」,旧字段 `subagentsEnabled` 同步),关闭时模型拿不到 `agent.spawn`,服务端也拒绝派生。 - 用法:会话调用 `agent.spawn {goal, capabilityAllowlist?, budget?, mode?, upstreamId?, await?}` 派生子会话(独立 agent_run、同一 runAgent 任务、独立会话文件),再用 `agent.await` 取各子会话的状态与汇报摘要(已脱敏)。 - 权限:子会话能力白名单必须是父会话的子集,只读(内部 plan_only)的会话只能派生只读子会话,父会话要求先看方案时子会话同样等方案选定,子会话继承父会话发起人,授权校验再按全部祖先会话的白名单与类别取交集。 +- 同目标去重(AG-4):同一父会话里目标归一化后相同(NFKC、忽略大小写、空白与标点)的子会话还在排队 / 运行,或 10 分钟内成功完成 / 已在等下载入库时,`agent.spawn` 不新建,返回已有子会话:输出 `reused: true`、`message`「已有同目标子会话…」、`result` 带它当前的结果摘要(还在跑时 `pending: true`)。失败、取消、超预算的子会话不复用(可换预算重派)。同一父会话的派生判定进程内串行,同一批并行的两次同目标调用也只建一个。会话树与结果行的「派出 N 个子会话」不变;进度行不把去重调用算成派出。恢复重放按幂等键命中原子会话,不走去重。 +- 子会话语言(AG-4):子会话 / 视觉子流程的系统提示有「汇报对象」一节——汇报交给父会话汇总(可带对象 ID),思考、步骤说明和汇报一律用中文,即使目标、工具结果或网页是英文;父会话的派生约束也要求子目标用中文写。回归测试 `packages/agent/test/system-prompt.test.ts`。子会话汇报回到用户面前有两条路:父会话的最终汇报(按 FIX-7 展示层兜底,子会话 ID 换成子会话目标名)和打开子会话页(同一展示组件,同样兜底);`agent.spawn` / `agent.await` 的步骤行不显示汇报原文。 - 限制:同一父会话下同时运行的子会话不超过 `maxConcurrent`(多出的排队等名额,不占 worker 也不调用模型),派生层数不超过 `maxDepth`,一个会话最多派生 10 个子会话。 - 预算:子会话预算从父会话剩余预算划拨(缺省剩余的一半,请求超过剩余直接拒绝),子会话超预算只结束它自己;父会话的工具调用 / token / 费用预算把子孙会话的实际用量算在内。 - 取消:中止父会话会递归取消全部未结束的子孙会话(排队中的也一样)。