refactor(contracts,agent): single capability spec and agent run event definitions in contracts
B3: CapabilityDescriptor gains replay; CapabilitySpec + capabilitySpec(id) assemble CAPABILITY_CATALOG + CAPABILITY_IO; drop CapabilityDefinition/CapabilityRisk. agent registry registers contracts CapabilitySpec (kind/allowedKinds instead of its own scope enum; approve also requires authorize). AgentRun port renamed AgentRunHandle. B9: contracts AgentRunEvent/AgentRunEventBody/AgentRunEventRow/AgentUsageTotals with row and SSE mapping helpers; agent onEvent receives AgentRunEvent, delivered serially in seq order, firstSeq continues numbering across processes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8f59bf3772
commit
db5352eecd
18 files changed
+553
-293
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentRunEvent } from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentSettings,
|
||||
type AuthorizationChecker,
|
||||
type CapabilityAuditRecord,
|
||||
@@ -40,6 +40,7 @@ export function makeFixture(opts: { slowMs?: number } = {}): Fixture {
|
||||
description: "按标题查询本地媒体库是否已有该作品及已有版本。",
|
||||
inputSchema: z.object({ title: z.string().min(1), year: z.number().int().optional() }),
|
||||
outputSchema: z.object({ title: z.string(), inLibrary: z.boolean(), versions: z.array(z.string()) }),
|
||||
kind: "read",
|
||||
scope: "read",
|
||||
riskLevel: "low",
|
||||
idempotent: true,
|
||||
@@ -56,7 +57,8 @@ export function makeFixture(opts: { slowMs?: number } = {}): Fixture {
|
||||
description: "提交下载计划到下载器。只代表已提交,不代表已入库。",
|
||||
inputSchema: z.object({ title: z.string(), quality: z.enum(["720p", "1080p", "2160p"]) }),
|
||||
outputSchema: z.object({ accepted: z.boolean(), idempotencyKey: z.string(), status: z.literal("submitted") }),
|
||||
scope: "execute",
|
||||
kind: "execute",
|
||||
scope: "organize.execute",
|
||||
riskLevel: "medium",
|
||||
idempotent: true,
|
||||
supportsPreview: true,
|
||||
@@ -82,6 +84,7 @@ export function makeFixture(opts: { slowMs?: number } = {}): Fixture {
|
||||
description: "查看站点发布详情(返回原始字段,可能含私有链接)。",
|
||||
inputSchema: z.object({ releaseId: z.string() }),
|
||||
outputSchema: z.object({ releaseId: z.string(), torrentUrl: z.string(), cookie: z.string(), note: z.string() }),
|
||||
kind: "read",
|
||||
scope: "read",
|
||||
riskLevel: "low",
|
||||
idempotent: true,
|
||||
@@ -127,6 +130,6 @@ export function settings(patch: Partial<AgentSettings> = {}): AgentSettings {
|
||||
return { ...FAKE_AGENT_SETTINGS, ...patch };
|
||||
}
|
||||
|
||||
export function types(events: AgentEvent[]): string[] {
|
||||
export function types(events: AgentRunEvent[]): string[] {
|
||||
return events.filter((e) => e.type !== "text_delta" && e.type !== "thinking_delta").map((e) => e.type);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CAPABILITY_IDS, capabilitySpec } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ describe("CapabilityRegistry.register", () => {
|
||||
description: "说明",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: z.object({}),
|
||||
kind: "read" as const,
|
||||
scope: "read" as const,
|
||||
riskLevel: "low" as const,
|
||||
idempotent: true,
|
||||
@@ -44,13 +46,41 @@ describe("CapabilityRegistry.register", () => {
|
||||
expect(() => r.register({ ...base, id: "bad id" }, () => ({}))).toThrow(/域.动作/);
|
||||
r.register({ ...base, id: "catalog.lookup" }, () => ({}));
|
||||
expect(() => r.register({ ...base, id: "catalog.lookup" }, () => ({}))).toThrow(/已注册/);
|
||||
expect(() => r.register({ ...base, id: "cache.cleanup", scope: "execute", idempotent: false }, () => ({}))).toThrow(
|
||||
expect(() => r.register({ ...base, id: "cache.cleanup", kind: "execute", idempotent: false }, () => ({}))).toThrow(
|
||||
/replay/,
|
||||
);
|
||||
expect(() => r.resolve(["catalog.lookup", "nope.nope"])).toThrow(/未注册/);
|
||||
expect(r.get("catalog.lookup")?.toolName).toBe("catalog__lookup");
|
||||
});
|
||||
|
||||
it("首批 14 个能力可直接用 contracts 的 capabilitySpec 注册", () => {
|
||||
const r = new CapabilityRegistry();
|
||||
for (const id of CAPABILITY_IDS) r.register(capabilitySpec(id), () => ({}) as never);
|
||||
expect(r.list().map((c) => c.descriptor.id)).toEqual([...CAPABILITY_IDS]);
|
||||
expect(r.inputJsonSchema("release.search").type).toBe("object");
|
||||
});
|
||||
|
||||
it("approve 类能力同样必须经授权回调,且生成幂等键", async () => {
|
||||
const seen: string[] = [];
|
||||
const r = new CapabilityRegistry({
|
||||
authorize: ({ descriptor }) => {
|
||||
seen.push(descriptor.id);
|
||||
return { allowed: true, approvalId: "ap-1" };
|
||||
},
|
||||
});
|
||||
r.register({ ...base, id: "approval.decide", kind: "approve", replay: "never" }, (_i, c) => {
|
||||
expect(c.idempotencyKey).toMatch(/^approval\.decide:v1:/);
|
||||
return {};
|
||||
});
|
||||
await r.invoke("approval.decide", {}, ctx());
|
||||
expect(seen).toEqual(["approval.decide"]);
|
||||
await expect(
|
||||
new CapabilityRegistry()
|
||||
.register({ ...base, id: "approval.decide", kind: "approve" }, () => ({}))
|
||||
.invoke("approval.decide", {}, ctx()),
|
||||
).rejects.toMatchObject({ code: "unauthorized" });
|
||||
});
|
||||
|
||||
it("输入 JSON Schema 可给模型使用(无 $schema)", () => {
|
||||
const { registry } = makeFixture();
|
||||
const schema = registry.inputJsonSchema("download.submit");
|
||||
@@ -97,7 +127,7 @@ describe("CapabilityRegistry.invoke", () => {
|
||||
expect(fx.audits.at(-1)).toMatchObject({ outcome: "ok", grantId: "grant:download.submit" });
|
||||
});
|
||||
|
||||
it("未注入授权回调:read 放行、execute 一律拒绝;allowedScopes 限制作用域", async () => {
|
||||
it("未注入授权回调:read 放行、execute 一律拒绝;allowedKinds 限制能力类别", async () => {
|
||||
const r = new CapabilityRegistry();
|
||||
const fx = makeFixture();
|
||||
const inv = fx.registry.get("inventory.query")!;
|
||||
@@ -113,7 +143,7 @@ describe("CapabilityRegistry.invoke", () => {
|
||||
fx.registry.invoke(
|
||||
"download.submit",
|
||||
{ title: "x", quality: "720p" },
|
||||
ctx({ allowedScopes: ["read", "preview"] }),
|
||||
ctx({ allowedKinds: ["read", "preview"] }),
|
||||
),
|
||||
).rejects.toMatchObject({ code: "unauthorized" });
|
||||
expect(fx.submitted).toHaveLength(0);
|
||||
@@ -158,6 +188,7 @@ describe("CapabilityRegistry.invoke", () => {
|
||||
description: "说明",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: z.object({ ok: z.boolean() }),
|
||||
kind: "read" as const,
|
||||
scope: "read" as const,
|
||||
riskLevel: "low" as const,
|
||||
idempotent: true,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { AgentRunEvent } from "@umt/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentRun,
|
||||
type AgentRunHandle,
|
||||
type AgentSettings,
|
||||
checkToolCall,
|
||||
createFakeRuntime,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { crashScript, RUN_ID } from "./fixtures/crash-script.ts";
|
||||
import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts";
|
||||
|
||||
const opened: AgentRun[] = [];
|
||||
const opened: AgentRunHandle[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(opened.splice(0).map((r) => r.close()));
|
||||
});
|
||||
@@ -42,9 +42,20 @@ const dune: FakeScript = {
|
||||
async function start(
|
||||
fx: Fixture,
|
||||
scripts: FakeScript[],
|
||||
opts: { capabilities?: string[]; settings?: Partial<AgentSettings>; runId?: string; sessionDir?: string } = {},
|
||||
opts: {
|
||||
capabilities?: string[];
|
||||
settings?: Partial<AgentSettings>;
|
||||
runId?: string;
|
||||
sessionDir?: string;
|
||||
firstSeq?: number;
|
||||
onEvent?: (e: AgentRunEvent) => void | Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const { runtime, fake } = createFakeRuntime({ registry: fx.registry, scripts });
|
||||
const { runtime, fake } = createFakeRuntime({
|
||||
registry: fx.registry,
|
||||
scripts,
|
||||
...(opts.onEvent ? { onEvent: opts.onEvent } : {}),
|
||||
});
|
||||
const sessionDir = opts.sessionDir ?? (await tempDir());
|
||||
const run = await runtime.createRun({
|
||||
runId: opts.runId ?? "run-1",
|
||||
@@ -54,6 +65,7 @@ async function start(
|
||||
settings: settings(opts.settings),
|
||||
sessionDir,
|
||||
actor: { actorId: "user:1", taskId: "task:1", scopeIds: ["media:dune"] },
|
||||
...(opts.firstSeq !== undefined ? { firstSeq: opts.firstSeq } : {}),
|
||||
});
|
||||
opened.push(run);
|
||||
return { run, fake, sessionDir };
|
||||
@@ -106,12 +118,12 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
it("events() 可按 seq 回放并在 close 后结束", async () => {
|
||||
const fx = makeFixture();
|
||||
const { run } = await start(fx, [{ match: "Dune", steps: [], finalText: "库里已有。" }]);
|
||||
const collected: AgentEvent[] = [];
|
||||
const collected: AgentRunEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const e of run.events()) collected.push(e);
|
||||
})();
|
||||
await run.prompt();
|
||||
const tail: AgentEvent[] = [];
|
||||
const tail: AgentRunEvent[] = [];
|
||||
const replay = (async () => {
|
||||
for await (const e of run.events(1)) tail.push(e);
|
||||
})();
|
||||
@@ -122,6 +134,31 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
expect(collected.at(-1)?.type).toBe("run_completed");
|
||||
});
|
||||
|
||||
it("onEvent 按 seq 串行投递 contracts AgentRunEvent;firstSeq 续接序号,close 等待投递完成", async () => {
|
||||
const fx = makeFixture();
|
||||
const delivered: AgentRunEvent[] = [];
|
||||
const { run } = await start(fx, [{ match: "Dune", steps: [], finalText: "库里已有。" }], {
|
||||
firstSeq: 10,
|
||||
// 故意让前面的事件落库更慢,验证不会乱序
|
||||
onEvent: async (e) => {
|
||||
await new Promise((r) => setTimeout(r, e.seq === 10 ? 20 : 0));
|
||||
delivered.push(e);
|
||||
},
|
||||
});
|
||||
await run.prompt();
|
||||
const tail: AgentRunEvent[] = [];
|
||||
const replay = (async () => {
|
||||
for await (const e of run.events(11)) tail.push(e);
|
||||
})();
|
||||
await run.close();
|
||||
await replay;
|
||||
const seqs = run.history().map((e) => e.seq);
|
||||
expect(seqs[0]).toBe(10);
|
||||
expect(delivered.map((e) => e.seq)).toEqual(seqs);
|
||||
expect(tail[0]?.seq).toBe(11);
|
||||
for (const e of delivered) expect(AgentRunEvent.safeParse(e).success, e.type).toBe(true);
|
||||
});
|
||||
|
||||
it("白名单:未注册工具(bash)与未列入白名单的已注册能力都被拒绝并产生事件,不执行", async () => {
|
||||
const fx = makeFixture();
|
||||
fx.grants.set("user:1", new Set(["download.submit"]));
|
||||
|
||||
Reference in new issue
Block a user