feat(agent): 用对话起草巡检(schedule.draft_check + 起草巡检 Skill + 计划页入口与对话卡片)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 00:04:31 +08:00
1 parent 8eb8a68295
commit 7c4ad901d9
14 files changed
+408 -7

No files matched your search

@@ -217,6 +217,14 @@ export function createAuthorizer(deps: {
}
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
if (d.id === "metadata.enrich") return { allowed: true };
// schedule.draft_check 只建未启用的巡检(不会自己运行,用户检查后启用);只放行用户发起的对话,
// 定时巡检 / 事件触发的会话不能再造巡检
if (d.id === "schedule.draft_check") {
if (!runId || (ctx.source !== "agent" && ctx.source !== "task"))
return { allowed: false, reason: "只有智能体对话可以起草巡检" };
const r = await db.selectFrom("agent_run").select("trigger").where("id", "=", runId).executeTakeFirst();
return r?.trigger === "user" ? { allowed: true } : { allowed: false, reason: "只在用户发起的对话里起草巡检" };
}
// 先看方案再执行:用户选定方案前,Agent 的执行类调用一律拒绝
if (runId && d.kind === "execute" && (ctx.source === "agent" || ctx.source === "task")) {
const block = await proposeFirstBlock(db, runId);
@@ -0,0 +1,56 @@
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { CAPABILITY_CATALOG, capabilitySpec, KnownCapabilityId } from "@umt/contracts";
import type { ServerContext } from "../types.ts";
import { createAgentCheck } from "./store.ts";
// schedule.draft_check:智能体按用户的自然语言需求起草巡检。建成「未启用」,不会自己运行;
// 用户在计划页检查、修改后再启用(授权只放行用户发起的对话,见 agent/authorize.ts)。
/** 巡检只开放只读 / 预览 / 提案类能力(与计划页表单一致),且不能再起草巡检 */
const ALLOWED = new Set(
Object.values(CAPABILITY_CATALOG)
.filter((c) => ["read", "preview", "propose"].includes(c.kind) && c.id !== "schedule.draft_check")
.map((c) => c.id as string),
);
export function registerScheduleCapabilities(registry: CapabilityRegistry, ctx: ServerContext): void {
registry.register(capabilitySpec("schedule.draft_check"), async (input) => {
const asked = [...new Set(input.capabilities ?? [])];
const caps = asked.filter((c) => KnownCapabilityId.safeParse(c).success && ALLOWED.has(c));
const dropped = asked.filter((c) => !caps.includes(c));
let row: Awaited<ReturnType<typeof createAgentCheck>>;
try {
row = await createAgentCheck(
ctx.db,
{
name: input.name,
description: "智能体按对话起草",
enabled: false,
schedule: { type: "interval", everyMs: Math.round(input.everyHours * 3600_000) },
config: {
goalTemplate: input.goal,
capabilityAllowlist: caps as KnownCapabilityId[],
budget: {
...(input.maxToolCalls ? { maxToolCalls: input.maxToolCalls } : {}),
...(input.maxDurationSec ? { maxDurationSec: input.maxDurationSec } : {}),
},
triggerCondition: input.triggerCondition ?? "always",
reportAsDecision: input.reportAsDecision ?? false,
},
},
new Date(),
);
} catch (e) {
throw new CapabilityError("precondition", `巡检没建成:${(e as Error).message}`);
}
return {
jobId: row.id,
name: row.name,
everyHours: input.everyHours,
capabilities: caps,
dropped,
enabled: false as const,
next: "已建成未启用的巡检。请用户到「计划」页检查目标、间隔和能力,确认后打开开关启用。",
};
});
}
@@ -1,5 +1,6 @@
import type { ServerContext, ServerModule } from "../types.ts";
import { builtinAgentCheckDefs } from "./agent-checks.ts";
import { registerScheduleCapabilities } from "./capabilities.ts";
import { registerScheduledJob, scheduleRegistry } from "./registry.ts";
import { registerScheduleRoutes } from "./routes.ts";
import { Scheduler, type SchedulerOptions } from "./scheduler.ts";
@@ -30,6 +31,9 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
},
registerTasks() {},
registerCapabilities(registry, ctx) {
registerScheduleCapabilities(registry, ctx);
},
start(ctx: ServerContext) {
if (opts.builtinChecks !== false) for (const def of builtinAgentCheckDefs(ctx)) registerScheduledJob(ctx, def);
// 备份目前由宿主机 scripts/backup.sh(cron / 手动)执行,这里只占位,看板上显示"未配置"
@@ -0,0 +1,76 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { registerScheduleCapabilities } from "../../src/modules/schedules/capabilities.ts";
import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts";
// schedule.draft_check:智能体按对话起草巡检 → 建成未启用的 agent_check;只读能力白名单;只放行用户发起的对话
const pg = await pgAvailable();
// biome-ignore lint/suspicious/noExplicitAny: 能力输出按 schema 校验过,测试里直接取字段
type Any = any;
describe.skipIf(!pg)("起草巡检能力(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness("draftcheck");
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
registerScheduleCapabilities(registry, h.ctx);
(h.ctx as { capabilities: unknown }).capabilities = registry;
}, 120_000);
afterAll(async () => {
await h?.close();
});
const run = async (trigger: "user" | "schedule") =>
(
await h.db
.insertInto("agent_run")
.values({ trigger, goal: "起草巡检", status: "running", mode: "execute" } as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
const invoke = (input: Record<string, unknown>, agentRunId: string) =>
h.ctx.capabilities.invoke("schedule.draft_check", input, {
actorId: "agent",
taskId: crypto.randomUUID(),
agentRunId,
scopeIds: [],
signal: new AbortController().signal,
source: "agent",
}) as Promise<Any>;
const input = {
name: "失败下载日报",
goal: "检查下载器里失败或卡住的任务,有就写一段话汇报",
everyHours: 24,
capabilities: ["download.list", "downloader.list", "download.submit", "not.exists"],
triggerCondition: "stalled_downloads",
reportAsDecision: true,
};
it("对话里起草:建成未启用的巡检;写入 / 不存在的能力去掉", async () => {
const out = await invoke(input, await run("user"));
expect(out).toMatchObject({
enabled: false,
capabilities: ["download.list", "downloader.list"],
dropped: ["download.submit", "not.exists"],
});
const job = await h.db
.selectFrom("scheduled_job")
.selectAll()
.where("id", "=", out.jobId)
.executeTakeFirstOrThrow();
expect(job).toMatchObject({ kind: "agent_check", enabled: false, builtin: false, name: "失败下载日报" });
expect(job.schedule).toMatchObject({ type: "interval", everyMs: 24 * 3600_000 });
expect(job.config).toMatchObject({
goalTemplate: input.goal,
capabilityAllowlist: ["download.list", "downloader.list"],
triggerCondition: "stalled_downloads",
reportAsDecision: true,
});
});
it("定时巡检的会话里不能再起草巡检", async () => {
const before = await h.db.selectFrom("scheduled_job").select("id").execute();
await expect(invoke(input, await run("schedule"))).rejects.toThrow(/用户发起的对话/);
expect(await h.db.selectFrom("scheduled_job").select("id").execute()).toHaveLength(before.length);
});
});