Files
UHD_Media_Tool/apps/web/app/components/agent/mock.ts
T
rootandClaude Opus 5.5 459991421e feat(agent): Pi subagents via agent.spawn / agent.await
- agent_run gains depth / spawn_reason (0029); agent_run_event types
  subagent_spawned / subagent_event / subagent_completed
- agent.spawn (propose, medium, replay never): allowlist ⊆ parent, mode not
  above parent, budget carved from parent's remainder, idempotent per run+input;
  settings.pi.subagents {enabled, maxConcurrent, maxDepth} (subagentsEnabled kept as alias)
- child runs claim a concurrency slot under an advisory lock (waitForFact while
  queued); agent.await polls business tables without calling the model
- parent abort cascades to all descendants; parent runtime budget counts
  descendant usage; child events mirrored into the parent stream by parent seq
- authorize caps every child call by all ancestors' allowlists/kinds
- session page shows parent link, inherited info and the subagent tree
- e2e E11; runbook section on subagents

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 02:49:37 +08:00

364 lines
12 KiB
TypeScript

import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
type Step = { delayMs: number; body: AgentRunEventBody };
type Entry = {
run: AgentRunView;
steps: Step[];
startedAt: number;
abortedAt: number | null;
appended: RunMessage[];
};
const store = new Map<string, Entry>();
let seeded = false;
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const iso = (ms: number) => new Date(ms).toISOString();
const USAGE: AgentUsageTotals = {
inputTokens: 18_420,
outputTokens: 1_236,
cacheReadTokens: 12_800,
cacheWriteTokens: 2_048,
totalTokens: 19_656,
costUsd: 0.042,
};
function chunks(text: string, size = 6): string[] {
const out: string[] = [];
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
return out;
}
function script(goal: string, mode: AgentMode): Step[] {
const s: Step[] = [];
let t = 0;
const push = (dt: number, body: AgentRunEventBody) => {
t += dt;
s.push({ delayMs: t, body });
};
const say = (text: string) => {
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
};
push(0, { type: "run_started", goal, resumed: false });
say("先确认本地库存与版本目标,再去站点找候选。");
push(400, {
type: "tool_call_start",
toolCallId: "t1",
capabilityId: "inventory.query",
inputSummary: '{"query":"沙丘:第二部","year":2024}',
recovery: false,
});
push(900, {
type: "tool_call_end",
toolCallId: "t1",
capabilityId: "inventory.query",
isError: false,
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
recovery: false,
});
push(300, {
type: "tool_call_start",
toolCallId: "t2",
capabilityId: "release.search",
inputSummary: '{"site":"audiences","q":"Dune Part Two 2160p","passkey":"[REDACTED]"}',
recovery: false,
});
push(1200, {
type: "tool_call_end",
toolCallId: "t2",
capabilityId: "release.search",
isError: false,
outputSummary:
"3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB",
recovery: false,
});
push(300, { type: "compaction", phase: "start", reason: "threshold" });
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
if (mode === "execute") {
push(300, {
type: "tool_call_start",
toolCallId: "t3",
capabilityId: "download.plan",
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
recovery: false,
});
push(800, {
type: "tool_call_end",
toolCallId: "t3",
capabilityId: "download.plan",
isError: false,
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
recovery: false,
});
push(300, {
type: "tool_call_rejected",
toolCallId: "t4",
toolName: "qb_delete_torrent",
capabilityId: "download.cleanup",
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
});
push(300, {
type: "tool_call_start",
toolCallId: "t5",
capabilityId: "download.submit",
inputSummary: '{"planId":"…9a2e"}',
recovery: false,
});
push(1000, {
type: "tool_call_end",
toolCallId: "t5",
capabilityId: "download.submit",
isError: false,
outputSummary: "已提交到 qb-main,标签 umt-managed,任务 ID …41d0",
recovery: false,
});
say("已提交 4K REMUX 下载。完成后会自动核验并 Copy 入库,原 1080p 保留。");
} else {
say("只要方案:建议提交①,预计占用缓存 78.4 GB,完成后 Copy 入库、保留 1080p。未执行任何写操作。");
}
push(200, { type: "run_completed", finalText: "", usage: USAGE });
return s;
}
function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, status: AgentRunView["status"]) {
return {
id,
createdAt: iso(createdAt),
updatedAt: iso(createdAt),
taskId: null,
parentRunId: null,
trigger: "user",
goal,
status,
provider: "anthropic",
model: "claude-sonnet",
thinkingLevel: "medium",
scope: { mode },
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
usage: {},
evidenceVersion: null,
sessionState: null,
summary: null,
toolCallCount: 0,
lastEventSeq: 0,
error: null,
cancelRequestedAt: null,
startedAt: iso(createdAt),
finishedAt: null,
createdBy: "admin",
mode,
dedupeKey: null,
depth: 0,
spawnReason: null,
} satisfies AgentRunView;
}
function add(goal: string, mode: AgentMode, startedAt: number, n: number): Entry {
const id = uuid(n);
const e: Entry = {
run: baseRun(id, goal, mode, startedAt, "running"),
steps: script(goal, mode),
startedAt,
abortedAt: null,
appended: [],
};
store.set(id, e);
return e;
}
function seed() {
if (seeded) return;
seeded = true;
const now = Date.now();
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
failed.steps = [
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
{
delayMs: 100,
body: {
type: "tool_call_start",
toolCallId: "x1",
capabilityId: "task.inspect",
inputSummary: '{"instance":"qb-main"}',
recovery: false,
},
},
{
delayMs: 200,
body: {
type: "tool_call_end",
toolCallId: "x1",
capabilityId: "task.inspect",
isError: true,
outputSummary: "下载器连接超时(qb-main 10.0.0.12:8080)",
recovery: false,
},
},
{
delayMs: 300,
body: {
type: "run_failed",
reason: "budget",
budget: "duration",
message: "超出单次运行时长预算(600 秒)",
usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 },
},
},
];
}
function revealed(e: Entry, now = Date.now()): AgentRunEvent[] {
const cut = e.abortedAt ?? now;
const out: AgentRunEvent[] = [];
let seq = 0;
for (const st of e.steps) {
if (e.startedAt + st.delayMs > cut) break;
out.push({ ...st.body, runId: e.run.id, seq: seq++, at: iso(e.startedAt + st.delayMs) } as AgentRunEvent);
}
if (e.abortedAt && !out.some((x) => x.type === "run_completed" || x.type === "run_failed")) {
out.push({
type: "run_failed",
reason: "aborted",
message: "用户中止",
usage: USAGE,
runId: e.run.id,
seq: seq++,
at: iso(e.abortedAt),
});
}
return out;
}
function view(e: Entry): AgentRunView {
const evs = revealed(e);
const end = evs.find((x) => x.type === "run_completed" || x.type === "run_failed");
const tools = evs.filter((x) => x.type === "tool_call_start").length;
const text = evs
.filter((x) => x.type === "text_delta")
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
const status: AgentRunView["status"] = !end
? "running"
: end.type === "run_completed"
? "succeeded"
: end.reason === "budget"
? "budget_exceeded"
: end.reason === "aborted"
? "cancelled"
: "failed";
const frac = end ? 1 : Math.min(1, evs.length / Math.max(1, e.steps.length));
return {
...e.run,
status,
toolCallCount: tools,
lastEventSeq: evs.length ? evs[evs.length - 1]!.seq : 0,
usage: end
? { ...end.usage, durationMs: new Date(end.at).getTime() - e.startedAt }
: {
inputTokens: Math.round(USAGE.inputTokens * frac),
outputTokens: Math.round(USAGE.outputTokens * frac),
totalTokens: Math.round(USAGE.totalTokens * frac),
costUsd: +(USAGE.costUsd * frac).toFixed(4),
durationMs: Date.now() - e.startedAt,
},
summary: end ? (end.type === "run_failed" ? end.message : text) : null,
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
finishedAt: end ? end.at : null,
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
};
}
export function MOCK_RUNS(): AgentRunView[] {
seed();
return [...store.values()].map(view).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function mockDetail(id: string, op?: { abort?: boolean; append?: string }): RunSnapshot {
seed();
const e = store.get(id);
if (!e) throw Object.assign(new Error("会话不存在(mock)"), { code: "not_found" });
if (op?.abort && !e.abortedAt) e.abortedAt = Date.now();
if (op?.append) {
// 追加消息:模拟 Agent 回一句确认
const at = Date.now() - e.startedAt;
e.appended.push({
id: uuid(9000 + e.appended.length),
runId: id,
createdAt: iso(Date.now()),
updatedAt: iso(Date.now()),
text: op.append,
status: "pending",
createdBy: "user:mock",
consumedAt: null,
});
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({
delayMs: at + 200 + i * 60,
body: { type: "text_delta", delta: c },
}));
if (last >= 0 && e.startedAt + e.steps[last]!.delayMs > Date.now()) e.steps.splice(last, 0, ...reply);
e.steps.sort((a, b) => a.delayMs - b.delayMs);
}
const run = view(e);
return { run, messages: e.appended, report: report(e, run) };
}
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */
function report(e: Entry, run: AgentRunView): GoalReport | null {
if (!run.finishedAt) return null;
const evs = revealed(e);
const submitted = evs.some((x) => x.type === "tool_call_end" && x.capabilityId === "download.submit" && !x.isError);
const text = evs
.filter((x) => x.type === "text_delta")
.map((x) => (x.type === "text_delta" ? x.delta : ""))
.join("");
return {
state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed",
summary: submitted
? "1 个下载已进入下载器(qb-main),尚未入库;入库以整理记录 succeeded 为准"
: run.status === "succeeded"
? "本次没有产生下载或整理动作"
: "运行未完成,没有产生业务动作",
submitted: submitted ? 1 : 0,
imported: 0,
inProgress: submitted ? 1 : 0,
failed: 0,
plans: submitted
? [{ planId: uuid(7001), status: "submitted", slotId: uuid(7003), sourceReleaseId: uuid(7004) }]
: [],
downloads: submitted
? [
{
downloadTaskId: uuid(7002),
planId: uuid(7001),
status: "downloading",
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
},
]
: [],
organization: [],
modelText: text || null,
generatedAt: run.finishedAt,
};
}
export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
seed();
const e = store.get(id);
if (!e) return [];
return revealed(e).filter((x) => x.seq > after);
}
export function mockCreateRun(goal: string, mode: AgentMode): AgentRunView {
seed();
const e = add(goal, mode, Date.now(), store.size + 1);
return view(e);
}