From 7c4ad901d9304334c1e92aeaecd92a5d5c33c55a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 00:04:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E7=94=A8=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E5=B7=A1=E6=A3=80(schedule.draft=5Fcheck=20+?= =?UTF-8?q?=20=E8=B5=B7=E8=8D=89=E5=B7=A1=E6=A3=80=20Skill=20+=20=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E9=A1=B5=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E5=8D=A1=E7=89=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + apps/server/src/modules/agent/authorize.ts | 8 ++ .../src/modules/schedules/capabilities.ts | 56 +++++++++++ apps/server/src/modules/schedules/index.ts | 4 + .../schedules/draft-check-capability.test.ts | 76 +++++++++++++++ .../components/agent/agent-conversation.tsx | 34 +++++++ .../components/agent/agent-helpers.test.ts | 16 +++- .../schedules/draft-check-dialog.tsx | 94 +++++++++++++++++++ .../components/schedules/schedule-board.tsx | 19 +++- packages/agent/skills/patrol-draft/SKILL.md | 49 ++++++++++ .../agent/src/prompt/layers/capabilities.ts | 7 +- packages/agent/test/skills.test.ts | 2 + packages/contracts/src/capabilities-io.ts | 33 +++++++ packages/contracts/src/capability.ts | 14 +++ 14 files changed, 408 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/modules/schedules/capabilities.ts create mode 100644 apps/server/test/schedules/draft-check-capability.test.ts create mode 100644 apps/web/app/components/schedules/draft-check-dialog.tsx create mode 100644 packages/agent/skills/patrol-draft/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8d75d5..2a79a7b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - 站点列表模式:操作按钮改成图标,数值列不换行,窄屏隐藏次要列;公开 BT 站的表只列出口、资源搜索、资源更新;公开站卡片不再显示 RSS。 - 智能体页会话分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话。 +### 新增 +- 用对话起草巡检:计划页「用对话起草」,一句话描述要定期检查什么、多久一次,智能体建一个未启用的巡检(目标、间隔、只读能力白名单、触发条件),对话里给「检查并启用」卡片,你在计划页检查、修改后启用。新能力 schedule.draft_check 只在用户发起的对话里可用,定时巡检里不能再造巡检。 + ## [0.3.19] - 2026-10-03 ### 修复 diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 1dc8f1c9..63b2376c 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -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); diff --git a/apps/server/src/modules/schedules/capabilities.ts b/apps/server/src/modules/schedules/capabilities.ts new file mode 100644 index 00000000..84462da3 --- /dev/null +++ b/apps/server/src/modules/schedules/capabilities.ts @@ -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>; + 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: "已建成未启用的巡检。请用户到「计划」页检查目标、间隔和能力,确认后打开开关启用。", + }; + }); +} diff --git a/apps/server/src/modules/schedules/index.ts b/apps/server/src/modules/schedules/index.ts index 3e0521d5..89ecf795 100644 --- a/apps/server/src/modules/schedules/index.ts +++ b/apps/server/src/modules/schedules/index.ts @@ -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 / 手动)执行,这里只占位,看板上显示"未配置" diff --git a/apps/server/test/schedules/draft-check-capability.test.ts b/apps/server/test/schedules/draft-check-capability.test.ts new file mode 100644 index 00000000..b9cf5c04 --- /dev/null +++ b/apps/server/test/schedules/draft-check-capability.test.ts @@ -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, 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; + 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); + }); +}); diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 2f9d6512..ca7a6f06 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -8,6 +8,7 @@ import { import { Ban, Brain, + CalendarCog, Check, ChevronDown, ChevronRight, @@ -38,6 +39,7 @@ import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { Markdown } from "~/components/agentui/markdown.tsx"; import { MessageMarker, MessageTyping } from "~/components/agentui/message.tsx"; import { StreamingResponse } from "~/components/agentui/streaming-response.tsx"; +import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { formatUsd } from "~/components/schedules/labels.ts"; @@ -542,6 +544,11 @@ export function AgentConversation({ until={p.body[0]?.at ?? null} /> ) : null} + {p.activity + .flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : [])) + .map((d) => ( + + ))} {p.body.map((t) => { const streaming = streamingText && t.key === lastKey; // 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息 @@ -601,6 +608,33 @@ export function AgentConversation({ ); } +/** 起草巡检(schedule.draft_check)成功的输出:巡检 ID 与名称(输出摘要可能被截断,按字段取) */ +export function draftCheckOf(call: ToolCallView): { jobId: string; name: string }[] { + if (call.capabilityId !== "schedule.draft_check" || call.status === "running" || call.status === "failed") return []; + if (isDenied(call) || !call.outputSummary) return []; + const jobId = /"jobId"\s*:\s*"([\w.-]+)"/.exec(call.outputSummary)?.[1]; + const name = /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.outputSummary)?.[1]; + return jobId ? [{ jobId, name: name ?? "巡检" }] : []; +} + +/** 对话里起草好的巡检:未启用,去计划页检查后启用 */ +function DraftCheckCard({ jobId, name }: { jobId: string; name: string }) { + return ( +
+
+

+ + 巡检草稿「{name}」未启用 +

+

到计划页检查目标、间隔和能力,确认后打开开关启用。

+
+ +
+ ); +} + /** 回合开始的时间:前一条用户消息;第一轮是会话开始 */ function turnSince(blocks: ConvBlock[], index: number, run: AgentRunView): string | null { const prev = blocks[index - 1]; diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index f5f868cb..475c8a09 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -1,6 +1,6 @@ import type { AgentRunEvent } from "@umt/contracts"; import { describe, expect, it } from "vitest"; -import { authDenial, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx"; +import { authDenial, draftCheckOf, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx"; import { matchesHistory } from "./agent-session-list.tsx"; import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts"; import { isLongReport } from "./conversation-model.ts"; @@ -168,3 +168,17 @@ describe("shortGoalTitle(会话短标题)", () => { expect(t).toBe("调查待处理事项(下载失败):下载异常:Dune.Part.Two"); }); }); + +describe("起草巡检卡片", () => { + it("成功的 schedule.draft_check 取巡检 ID 与名称;失败 / 其它能力不出卡片", () => { + const out = + '{"jobId":"check.custom.1a2b3c4d","name":"失败下载日报","everyHours":24,"capabilities":["download.list"]'; + expect( + draftCheckOf(call({ capabilityId: "schedule.draft_check", status: "succeeded", outputSummary: out })), + ).toEqual([{ jobId: "check.custom.1a2b3c4d", name: "失败下载日报" }]); + expect(draftCheckOf(call({ capabilityId: "schedule.draft_check", status: "failed", outputSummary: out }))).toEqual( + [], + ); + expect(draftCheckOf(call({ capabilityId: "site.list", status: "succeeded", outputSummary: out }))).toEqual([]); + }); +}); diff --git a/apps/web/app/components/schedules/draft-check-dialog.tsx b/apps/web/app/components/schedules/draft-check-dialog.tsx new file mode 100644 index 00000000..b72ac744 --- /dev/null +++ b/apps/web/app/components/schedules/draft-check-dialog.tsx @@ -0,0 +1,94 @@ +import { LoaderCircle, Sparkles } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { useCreateAgentRun } from "~/api/agent.ts"; +import { FormField } from "~/components/console/form-field.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { Textarea } from "~/components/ui/textarea.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +// 用对话起草巡检:用户一句话描述需求 → 发起智能体对话(起草巡检 Skill)→ 智能体建成未启用的巡检, +// 对话里给「检查并启用」卡片,用户在计划页检查、修改后启用。 + +const EXAMPLES = [ + "每天早上 9 点前看看有没有失败或卡住的下载,有就写成待处理", + "每隔 6 小时检查一下站点登录有没有失效", + "每周汇报一次订阅追更情况和缺集", +]; + +export function DraftCheckDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { + const [text, setText] = useState(""); + const create = useCreateAgentRun(); + const navigate = useNavigate(); + const submit = async () => { + const need = text.trim(); + if (need.length < 4) return; + try { + const run = await create.mutateAsync({ goal: `帮我起草一个巡检:${need}` }); + onOpenChange(false); + navigate(consolePath("agent", run.id)); + } catch (err) { + toast.error(errorMessage(err, "发起对话失败")); + } + }; + return ( + !create.isPending && onOpenChange(o)}> + + + 用对话起草巡检 + + 说说想定期检查什么、多久一次。智能体会建一个未启用的巡检,你检查后再启用。 + + +
{ + e.preventDefault(); + void submit(); + }} + > + +