Merge branch 'ag4'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
09bac0ea39
28 files changed
+1011
-59
No files matched your search
+8
-3
@@ -10,6 +10,12 @@
|
||||
- 媒体详情:作品和每集没有简介时显示一行小字「暂无简介」。已有的英文简介在下次刷新时按新规则清掉。
|
||||
- 存量重算脚本 `scripts/overview-recompute.ts`:找出语言不在简介链里的作品 / 季 / 集简介,不请求 TMDB,只用 `kv_cache` 里缓存的 TMDB 响应按新规则重算(缓存已淘汰的直接清空);缺省 dry-run,`--apply` 写库。见 `docs/runbook/metadata.md`「存量简介重算(MD-2)」。
|
||||
|
||||
### 变更(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 与指纹统一)
|
||||
@@ -115,10 +121,9 @@
|
||||
- 旧库只有库根"只登记 / 纳管"两种模式,没有首次扫描登记和启用前的影响预览。
|
||||
- Agent 不做主观选片,也没有规则学习与样本回放;策略预览只对最近 200 条已识别资源试算。
|
||||
- Emby 查询结果不参与收录决策,也没有本地资产与 Emby 的对账。
|
||||
- 站点 HTTP 访问失败不会自动转浏览器;原站视图是 1.5 秒截图轮询,没有在真实 camofox 上核验。
|
||||
- 观众、馒头、HHClub、CHD 只对 mock 与解析样本验证过,没有连真实账号。
|
||||
- 站点 HTTP 访问失败不会自动转浏览器。
|
||||
- 观众、馒头、CHD 只对 mock 与解析样本验证过,没有连真实账号。
|
||||
- 用户委托换源时,新提交与原任务的处置不在同一个计划里。
|
||||
- 站点限流按进程计数,api 与 worker 各算各的;跨进程令牌桶在 c2a 分支进行中。
|
||||
- 定时任务里的"备份"是占位,备份靠宿主机 cron 调 `umt backup`,恢复没有自动化演练。
|
||||
- 没有身份巡检;同一挂载根离线引起的多条待处理不按根因合并。
|
||||
- 没有调查整个 qB 实例(含未纳管任务)的能力。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<DB>,
|
||||
parentId: string,
|
||||
goal: string,
|
||||
now = Date.now(),
|
||||
): Promise<AgentRunRow | null> {
|
||||
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<v
|
||||
|
||||
// ── 能力处理器 ──
|
||||
|
||||
const spawnLocks = new Map<string, Promise<void>>();
|
||||
/** 同一父会话的派生判定串行(进程内);返回释放函数 */
|
||||
async function spawnLock(parentId: string): Promise<() => void> {
|
||||
const prev = spawnLocks.get(parentId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const mine = new Promise<void>((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<AgentRunRow> {
|
||||
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<RunScope>;
|
||||
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,
|
||||
|
||||
@@ -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<string>(["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([]);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,12 @@ async function guard<T>(fn: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepaliveTarget {
|
||||
/** 对会话做一次保活;会话已关闭返回 false */
|
||||
keepAlive(sessionId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepaliveOptions {
|
||||
/** 0 = 关闭保活 */
|
||||
intervalMs: number;
|
||||
log?: (level: "info" | "warn", msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export class BrowserKeepalive {
|
||||
/** sessionId → (登记键 → 登记) */
|
||||
private readonly holds = new Map<string, Map<string, Hold>>();
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private ticking: Promise<void> | 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<boolean>): () => 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<boolean>): 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, { sig: string; provider: BrowserProvider }>();
|
||||
private envSync: Promise<void> | 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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<T>,
|
||||
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<T> {
|
||||
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<string, number>();
|
||||
|
||||
/** 智能体会话在用这个浏览器会话:会话还在排队 / 运行期间保活(最后一次使用后最多 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 浏览器服务重启后自愈 ----------
|
||||
|
||||
/** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */
|
||||
|
||||
@@ -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<string, unknown>) => 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<string, unknown>): 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>, 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();
|
||||
|
||||
@@ -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({
|
||||
<span className="min-w-0 truncate">{head}</span>
|
||||
{denied ? <span className="shrink-0">· {denied} 项没做</span> : null}
|
||||
{failed ? <span className="shrink-0 text-destructive">· {failed} 项失败</span> : null}
|
||||
{counted ? <span className="shrink-0 tabular-nums">· {counted}</span> : null}
|
||||
{span !== null && !running ? <span className="shrink-0 tabular-nums">· {durationLabel(span)}</span> : null}
|
||||
<ChevronRight aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-90")} />
|
||||
</button>
|
||||
@@ -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 ? (
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting"
|
||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||
: run.status === "queued"
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground" data-slot="turn-status">
|
||||
<MessageTyping label={typingState} />
|
||||
<span className="tabular-nums">
|
||||
{typingState}
|
||||
{turnCount ? ` · ${turnCount}` : "…"}
|
||||
</span>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
@@ -521,7 +543,6 @@ export function AgentConversation({
|
||||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||||
{human ? <Markdown text={human.text} /> : t.text}
|
||||
</StreamingResponse>
|
||||
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -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 (
|
||||
<TechInfo
|
||||
title="正文原文"
|
||||
className="mt-1"
|
||||
items={[
|
||||
{
|
||||
label: "换掉的标识",
|
||||
value: (
|
||||
<ul className="space-y-0.5">
|
||||
{uniq.map((r) => (
|
||||
<li key={r.from}>
|
||||
{r.from} → {r.to || "(省略)"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
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 (
|
||||
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
|
||||
@@ -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 },
|
||||
]}
|
||||
|
||||
@@ -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 (
|
||||
<section aria-label="会话统计" className={cn("shrink-0", className)}>
|
||||
{/* 窄屏折行,不截断(费用在最后) */}
|
||||
<p data-slot="run-stats" className="flex flex-wrap gap-x-1.5 px-1 text-meta text-muted-foreground tabular-nums">
|
||||
{parts.map((t, i) => (
|
||||
<span key={t} className="whitespace-nowrap">
|
||||
{i ? "· " : ""}
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunOverviewBar({
|
||||
snapshot,
|
||||
status,
|
||||
@@ -338,6 +379,7 @@ export function AgentRunPanel({
|
||||
</StatusBadge>
|
||||
</div>
|
||||
) : null}
|
||||
<RunStatsLine snapshot={run.data} live={live} className={column} />
|
||||
<RunOverviewBar
|
||||
snapshot={run.data}
|
||||
status={shownStatus}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRunView } from "~/api/agent.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { AgentConversation, contextLabel, DeniedLines, StepSummary } from "./agent-conversation.tsx";
|
||||
import { AgentConversation, contextLabel, DeniedLines, ResultLine, StepSummary } from "./agent-conversation.tsx";
|
||||
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
|
||||
import { goalFromInput } from "./agent-run-panel.tsx";
|
||||
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
|
||||
import { arrangeHistory } from "./agent-session-list.tsx";
|
||||
import { type ActivityItem, activitySummary, buildConversation, deniedReason } from "./conversation-model.ts";
|
||||
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
|
||||
@@ -392,3 +392,53 @@ describe("输入区:/ 指令与上下文标签", () => {
|
||||
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("</button>")))).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");
|
||||
});
|
||||
});
|
||||
@@ -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<ToolCallView, "capabilityId" | "stat
|
||||
const a = activityOf(c.capabilityId);
|
||||
if (a.hidden) continue;
|
||||
if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue;
|
||||
// 同目标去重返回已有子会话(AG-4):没有新派出,不计「派出了 N 个子会话」
|
||||
if (c.capabilityId === "agent.spawn" && reusedSpawn(c.outputSummary)) continue;
|
||||
let g = groups.find((x) => 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<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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「容器经宿主代理」)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 / 费用预算把子孙会话的实际用量算在内。
|
||||
- 取消:中止父会话会递归取消全部未结束的子孙会话(排队中的也一样)。
|
||||
|
||||
+38
-1
@@ -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:接管期间保活、
|
||||
不记自动化活动、交还后停;桌面连接期间标签页被回收 → 保活时自愈重连;智能体会话结束后停、会话关闭撤销)。真机未核验。
|
||||
@@ -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` | 最多并发会话 / 容器内存上限 |
|
||||
|
||||
@@ -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<void> {
|
||||
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<NavigationResult> {
|
||||
|
||||
@@ -93,6 +93,14 @@ export interface BrowserProvider {
|
||||
*/
|
||||
reopenSession?(sessionId: string, req: ReopenSessionRequest): Promise<ReopenSessionResult>;
|
||||
|
||||
// ── AG-4 保活 ──
|
||||
/**
|
||||
* 对标签页做一次最轻的只读调用,让上游把它算作"有活动"(camofox 标签页空闲回收 TAB_INACTIVITY_MS 与会话空闲回收
|
||||
* SESSION_TIMEOUT_MS 都按 API 调用计;桌面模式里人的键鼠操作不经 API,不算活动)。不检查控制者、不改页面。
|
||||
* 标签页不在了报 not_found(调用方按 BR-3 自愈重连)。
|
||||
*/
|
||||
keepAlive?(ref: TabRef): Promise<void>;
|
||||
|
||||
// ── BR-4 浏览器 UA 与指纹统一、出口按站点 ──
|
||||
/** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */
|
||||
identity?(): Promise<BrowserIdentity | null>;
|
||||
|
||||
@@ -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";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -347,7 +347,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
"agent.spawn": d(
|
||||
"agent.spawn",
|
||||
"派生子会话",
|
||||
"把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报",
|
||||
"把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报。同一目标的子会话还在运行或 10 分钟内已完成时不新建,直接返回已有子会话(reused=true)",
|
||||
"propose",
|
||||
"AgentSpawn",
|
||||
"organize.execute",
|
||||
|
||||
Reference in new issue
Block a user