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, summary: r.summary, capabilities: scope.capabilities ?? [], budget: r.budget as Record, createdAt: r.created_at.toISOString(), finishedAt: toIso(r.finished_at), }; } /** snake_case 行 → camelCase(Date → ISO) */ function camel(row: object): Record { const out: Record = {}; 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`(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`not ${auto}`); } const cur = decode(query.cursor); if (cur) q = q.where(sql`(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`(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`(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`(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`(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(); }