Files
UHD_Media_Tool/apps/server/src/modules/agent/routes.ts
T
rootandClaude Opus 5.5 3692793dff feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见
- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换,
  头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗
- 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话
  (GET /agent/runs?roots=true,新增可选参数)
- 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用,
  可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs
- 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息
- 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 13:56:38 +08:00

612 lines
24 KiB
TypeScript

import { isCapabilityError } from "@umt/agent";
import {
type AgentGoalReport,
Api,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
import { route } from "./http.ts";
import { afterApprovalDecided, answerOptions } from "./interaction.ts";
import { type InvokeLimiter, sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "./invoke-limits.ts";
import { AgentRunError, abortRun, appendMessage, startRun } from "./service.ts";
import { descendants, descendantUsage } from "./subagents.ts";
import { agentUsage } from "./usage.ts";
// Agent 会话 API、持续授权 / 单次审批、能力目录与调用、业务任务。
/** crud() 生成的列表 query 类型被擦除,这里按合同收窄 */
type ListQuery = {
cursor?: string;
limit: number;
status?: string;
planId?: string;
kind?: string;
subjectId?: string;
};
const toIso = (d: Date | null) => (d ? d.toISOString() : null);
export function runToApi(r: AgentRunRow) {
return {
id: r.id,
taskId: r.task_id,
parentRunId: r.parent_run_id,
trigger: r.trigger,
goal: r.goal,
status: r.status,
provider: r.provider,
model: r.model,
thinkingLevel: r.thinking_level,
scope: r.scope,
budget: r.budget,
usage: r.usage,
evidenceVersion: r.evidence_version,
sessionState: null,
summary: r.summary,
toolCallCount: r.tool_call_count,
lastEventSeq: Number(r.last_event_seq),
error: r.error,
cancelRequestedAt: toIso(r.cancel_requested_at),
startedAt: toIso(r.started_at),
finishedAt: toIso(r.finished_at),
createdBy: r.created_by,
dedupeKey: r.dedupe_key,
depth: r.depth,
spawnReason: r.spawn_reason,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
};
}
/** 会话树节点(父会话 / 子孙会话) */
export function runToNode(r: AgentRunRow) {
const scope = (r.scope ?? {}) as { capabilities?: string[] };
return {
id: r.id,
parentRunId: r.parent_run_id,
goal: r.goal,
status: r.status,
depth: r.depth,
spawnReason: r.spawn_reason,
toolCallCount: r.tool_call_count,
usage: r.usage as Record<string, number>,
summary: r.summary,
capabilities: scope.capabilities ?? [],
budget: r.budget as Record<string, number>,
createdAt: r.created_at.toISOString(),
finishedAt: toIso(r.finished_at),
};
}
/** snake_case 行 → camelCase(Date → ISO) */
function camel(row: object): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row))
out[k.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())] = v instanceof Date ? v.toISOString() : v;
return out;
}
function mapErr(err: unknown): never {
if (err instanceof AgentRunError)
throw new ApiException(
err.code === "budget" ? "rate_limited" : err.code === "bad_request" ? "validation_failed" : err.code,
err.message,
);
throw err;
}
const encode = (at: Date, id: string) => Buffer.from(`${at.toISOString()}|${id}`).toString("base64url");
const decode = (c?: string) => {
if (!c) return null;
const [at, id] = Buffer.from(c, "base64url").toString().split("|");
return at && id ? { at, id } : null;
};
export function registerAgentRoutes(
app: ModuleApp,
ctx: ServerContext,
limiter: InvokeLimiter = sharedInvokeLimiter(),
) {
const db = () => ctx.db;
const dispatch = new InternalDispatcher(app);
// ── Agent 会话 ──
route(app, Api.agentRuns.usage, async () => agentUsage(db()));
route(app, Api.agentRuns.list, async ({ query }) => {
let q = db().selectFrom("agent_run").selectAll();
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId);
if (query.roots === "true") q = q.where("parent_run_id", "is", null);
if (query.group) {
// 自动 = 定时 / 事件触发,子会话按父会话归类
const auto = sql<boolean>`(trigger in ('schedule', 'event') or (trigger = 'subagent' and parent_run_id in
(select p.id from agent_run p where p.trigger in ('schedule', 'event'))))`;
q = q.where(query.group === "auto" ? auto : sql<boolean>`not ${auto}`);
}
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(runToApi),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.agentRuns.get, async ({ params, query }) => {
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!run) throw new ApiException("not_found", "智能体运行不存在");
const events = await db()
.selectFrom("agent_run_event")
.selectAll()
.where("run_id", "=", params.id)
.where("seq", ">", query.afterSeq)
.orderBy("seq")
.limit(query.limit + 1)
.execute();
const page = events.slice(0, query.limit);
const messages = await db()
.selectFrom("agent_run_message")
.selectAll()
.where("run_id", "=", params.id)
.orderBy("created_at")
.execute();
const report = await db()
.selectFrom("artifact")
.select("content")
.where("agent_run_id", "=", params.id)
.where("kind", "=", "report")
.orderBy("created_at", "desc")
.executeTakeFirst();
// 会话树:父会话 + 全部子孙会话;合计用量 = 本会话 + 子孙会话
const parent = run.parent_run_id
? await db().selectFrom("agent_run").selectAll().where("id", "=", run.parent_run_id).executeTakeFirst()
: undefined;
const kids = await descendants(ctx, run.id);
const own = (run.usage ?? {}) as { totalTokens?: number; costUsd?: number };
const ext = await descendantUsage(ctx, run.id);
return {
run: runToApi(run),
parent: parent ? runToNode(parent) : null,
children: kids.map(runToNode),
familyUsage: {
runs: kids.length + 1,
toolCalls: run.tool_call_count + ext.toolCalls,
totalTokens: (own.totalTokens ?? 0) + ext.totalTokens,
costUsd: (own.costUsd ?? 0) + ext.costUsd,
},
events: page.map((e) => ({
runId: e.run_id,
seq: Number(e.seq),
type: e.type,
at: e.at.toISOString(),
payload: e.payload,
})),
nextAfterSeq: events.length > query.limit ? Number(page.at(-1)!.seq) : null,
messages: messages.map(camel),
report: (report?.content as AgentGoalReport | undefined) ?? null,
};
});
route(
app,
Api.agentRuns.create,
async ({ body, actorId, c }) => {
// 运行模式已取消:用户会话一律按授权执行;旧客户端传 mode=plan_only 按"先看方案再执行"处理
const proposeFirst = body.proposeFirst || body.mode === "plan_only";
if (body.mode) c.header("Deprecation", "true");
const r = await startRun(ctx, {
goal: body.goal,
mode: "execute",
capabilityAllowlist: body.capabilityAllowlist,
trigger: "user",
createdBy: actorId,
session: {
// 控制台管理员会话发起:需审批的动作在步骤处出审批卡(只有管理员会话能批准)
interactive: c.get("principal")?.kind === "admin_session",
proposeFirst,
},
}).catch(mapErr);
return runToApi(r.run);
},
{ status: 201 },
);
route(app, Api.agentRuns.message, async ({ params, body, actorId }) =>
runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)),
);
route(app, Api.agentRuns.choose, async ({ params, body, actorId, c }) => {
const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!run) throw new ApiException("not_found", "智能体运行不存在");
if (body.optionId === null && !body.reason?.trim())
throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案");
// 选定即批准只认控制台管理员会话(与 PATCH /approvals 同一口径);API Key 作答只选方案
const r = await answerOptions(ctx, run.id, body, actorId, {
approver: c.get("principal")?.kind === "admin_session",
});
if (r.status === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择");
if (r.status === "bad_option") throw new ApiException("validation_failed", "没有这个方案");
if (r.status === "closed") throw new ApiException("conflict", "这项方案选择已处理");
if (r.status === "ok" && r.approvalId)
await audit(ctx, actorId, "approval.approved", r.approvalId, {
via: "option",
runId: run.id,
decisionId: body.decisionId,
optionId: body.optionId,
});
const fresh = await db().selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
return runToApi(fresh);
});
route(app, Api.agentRuns.abort, async ({ params, body, actorId }) =>
runToApi(await abortRun(ctx, params.id, body.reason, actorId).catch(mapErr)),
);
// ── 持续授权 ──
route(app, Api.automationGrants.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("automation_grant").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.automationGrants.get, async ({ params }) => {
const r = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "授权不存在");
return camel(r);
});
route(
app,
Api.automationGrants.create!,
async ({ body, actorId }) => {
const r = await db()
.insertInto("automation_grant")
.values({
action: body.action,
policy_revision_id: body.policyRevisionId ?? null,
scope: JSON.stringify(body.scope ?? {}),
budget: JSON.stringify(body.budget ?? {}),
granted_by: actorId,
reason: body.reason ?? null,
expires_at: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "automation_grant.create", r.id, { action: r.action });
return camel(r);
},
{ status: 201 },
);
route(app, Api.automationGrants.update!, async ({ params, body, actorId }) => {
const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "授权不存在");
if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at)
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围需新建授权");
const r = await db()
.updateTable("automation_grant")
.set({
...(body.status === "revoked" ? { status: "revoked", revoked_at: new Date(), revoked_by: actorId } : {}),
...(body.expiresAt !== undefined ? { expires_at: body.expiresAt ? new Date(body.expiresAt) : null } : {}),
...(body.reason !== undefined ? { reason: body.reason } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "automation_grant.update", r.id, { status: r.status });
return camel(r);
});
// ── 单次审批 ──
route(app, Api.approvals.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("approval").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
if (query.planId) q = q.where("plan_id", "=", query.planId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.approvals.get, async ({ params }) => {
const r = await db().selectFrom("approval").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "审批不存在");
return camel(r);
});
route(
app,
Api.approvals.create,
async ({ body, actorId }) => {
const b = await planBinding(db(), body.planType, body.planId);
if (!b) throw new ApiException("not_found", "计划不存在");
const r = await db()
.insertInto("approval")
.values({
plan_type: body.planType,
plan_id: body.planId,
payload_hash: b.payloadHash,
expected_revision: b.revision,
requested_by: actorId,
expires_at: new Date(Date.now() + body.expiresInSec * 1000),
reason: body.reason ?? null,
})
.returningAll()
.executeTakeFirstOrThrow();
return camel(r);
},
{ status: 201 },
);
route(app, Api.approvals.update, async ({ params, body, actorId }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("approval")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur) throw new ApiException("not_found", "审批不存在");
if (cur.payload_hash !== body.payloadHash)
throw new ApiException("conflict", "计划已变化(payloadHash 不一致),重新申请审批");
const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"];
if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`);
return trx
.updateTable("approval")
.set({ status: body.status, decided_by: actorId, decided_at: new Date(), reason: body.reason ?? cur.reason })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
});
await audit(ctx, actorId, `approval.${body.status}`, r.id, { planType: r.plan_type, planId: r.plan_id });
// 会话内审批:写关联待处理事项的结果(拒绝带理由),唤醒挂起在这份审批上的会话
await afterApprovalDecided(ctx, r, actorId);
return camel(r);
});
// ── 能力目录与调用(Web / MCP 与 Agent 同一条 invoke 路径)──
route(app, Api.capabilities.list, async () => ({
items: ctx.capabilities.list().map(
(c): CapabilityDescriptor => ({
...CAPABILITY_CATALOG[c.descriptor.id as KnownCapabilityId],
}),
),
}));
route(app, Api.capabilities.invoke, async ({ params, body, actor, actorId, c }) => {
const entry = ctx.capabilities.get(params.capabilityId);
if (!entry) throw new ApiException("not_found", `能力未注册:${params.capabilityId}`);
const scopes = (c.get("principal")?.scopes ?? actor?.scopes ?? []) as string[];
if (!hasScope(scopes, entry.descriptor.scope))
throw new ApiException("forbidden", `调用 ${entry.descriptor.id} 需要 scope: ${entry.descriptor.scope}`);
if (body.preview && !entry.descriptor.supportsPreview)
throw new ApiException("bad_request", `${entry.descriptor.id} 不支持预览`);
try {
// 外部 IO 型能力(站点实时搜索/详情刷新、TMDB):15s 超时 + 按上游限并发
const output = await limiter.run(params.capabilityId, body.input, c.req.raw.signal, (signal) =>
ctx.capabilities.invoke(params.capabilityId, body.input, {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
scopeIds: [],
signal: signal ?? c.req.raw.signal,
source: "web",
}),
);
const o = (output ?? {}) as { taskId?: unknown; planId?: unknown; plan?: { id?: unknown } | null };
return {
output,
taskId: typeof o.taskId === "string" ? o.taskId : null,
planId: typeof o.planId === "string" ? o.planId : typeof o.plan?.id === "string" ? o.plan.id : null,
};
} catch (err) {
capabilityHttpError(err);
}
});
// 手动资源选择不依赖订阅,提交后仍走共同下载及整理任务。
route(app, Api.acquisitionPlans.create, async ({ body: input, actorId, c }) => {
let body = input;
// 下载前指定了作品:走资源指定身份的同一端点(建 / 绑作品、人工锁、审计),再按该作品下载
if (body.identity) {
const { identity, ...rest } = body;
const assigned = await dispatch.call<{ mediaEntityId: string }>(
c,
"POST",
`/source-releases/${body.sourceReleaseId}/identity`,
{ ...identity, reason: "手动下载时指定" },
);
body = { ...rest, entityId: assigned.mediaEntityId };
}
const rel = await db()
.selectFrom("source_release")
.select("media_entity_id")
.where("id", "=", body.sourceReleaseId)
.executeTakeFirst();
if (
rel &&
!rel.media_entity_id &&
!body.entityId &&
!body.allowUnrecognized &&
ctx.capabilities.get("identity.resolve")
) {
try {
await ctx.capabilities.invoke(
"identity.resolve",
{ sourceReleaseId: body.sourceReleaseId },
{
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
scopeIds: [],
signal: c.req.raw.signal,
source: "web",
},
);
} catch (err) {
ctx.logger.warn(
{ sourceReleaseId: body.sourceReleaseId, error: err instanceof Error ? err.message : String(err) },
"手动下载识别未完成,允许确认后下载",
);
}
}
const result = await planManualAcquisition(ctx, body);
if (result.plan)
await audit(ctx, actorId, "acquisition_plan.manual_submit", result.plan.id, {
sourceReleaseId: body.sourceReleaseId,
taskId: result.taskId,
importMode: body.importMode,
note: body.note ?? null,
});
return result;
});
// ── 产物(整理计划 / 汇报 / 证据):只读 ──
route(app, Api.artifacts.list, async ({ query: raw }) => {
const query = raw as ListQuery & { agentRunId?: string; taskId?: string };
let q = db().selectFrom("artifact").selectAll();
if (query.agentRunId) q = q.where("agent_run_id", "=", query.agentRunId);
if (query.taskId) q = q.where("task_id", "=", query.taskId);
if (query.kind) q = q.where("kind", "=", query.kind as never);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(camel),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.artifacts.get, async ({ params }) => {
const r = await db().selectFrom("artifact").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "产物不存在");
return camel(r);
});
// ── 业务任务 ──
route(app, Api.tasks.list, async ({ query: raw }) => {
const query = raw as ListQuery;
let q = db().selectFrom("task").selectAll();
if (query.status) q = q.where("status", "=", query.status as never);
if (query.kind) q = q.where("kind", "=", query.kind);
if (query.subjectId) q = q.where("subject_id", "=", query.subjectId);
const cur = decode(query.cursor);
if (cur) q = q.where(sql<boolean>`(created_at, id) < (${cur.at}::timestamptz, ${cur.id}::uuid)`);
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(query.limit + 1)
.execute();
const items = rows.slice(0, query.limit);
const last = items.at(-1);
return {
items: items.map(taskToApi),
nextCursor: rows.length > query.limit && last ? encode(last.created_at, last.id) : null,
};
});
route(app, Api.tasks.get, async ({ params }) => {
const r = await db().selectFrom("task").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "任务不存在");
return taskToApi(r);
});
route(app, Api.tasks.operations, async ({ params }) => {
const rows = await db()
.selectFrom("asset_operation")
.selectAll()
.where("task_id", "=", params.id)
.orderBy("created_at")
.execute();
return { items: rows.map(camel) };
});
route(app, Api.tasks.cancel, async ({ params, body }) => {
try {
return taskToApi((await cancelTask(ctx, params.id, body.reason)).row);
} catch (err) {
if (isCapabilityError(err))
throw new ApiException(err.code === "not_found" ? "not_found" : "conflict", err.message);
throw err;
}
});
route(app, Api.tasks.retry, async ({ params, actorId }) => {
try {
return taskToApi(await retryTask(ctx, params.id, actorId));
} catch (err) {
if (isCapabilityError(err))
throw new ApiException(err.code === "not_found" ? "not_found" : "conflict", err.message);
throw err;
}
});
}
/** 能力错误 → HTTP 错误码 */
function capabilityHttpError(err: unknown): never {
if (err instanceof UpstreamBusyError) throw new ApiException("rate_limited", err.message);
if (err instanceof UpstreamTimeoutError) throw new ApiException("upstream_unavailable", err.message);
if (!isCapabilityError(err)) throw err;
const code =
err.code === "unauthorized"
? "forbidden"
: err.code === "not_found"
? "not_found"
: err.code === "conflict"
? "conflict"
: err.code === "precondition" || err.code === "budget"
? "precondition_failed"
: err.code === "invalid_input"
? "validation_failed"
: "internal";
throw new ApiException(code, err.message);
}
async function audit(ctx: ServerContext, actorId: string, action: string, targetId: string, payload: object) {
await ctx.db
.insertInto("audit_event")
.values({
actor_type: actorId.startsWith("api_key:") ? "api_key" : "user",
actor_id: actorId,
action,
target_type: action.split(".")[0]!,
target_id: targetId,
payload: JSON.stringify(payload),
})
.execute();
}