import type { AgentGoalReport, DecisionOption, EventPayload } from "@umt/contracts"; import { sql } from "@umt/db"; import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks"; import { z } from "zod"; import type { ServerContext } from "../types.ts"; import { retryTask } from "./capabilities.ts"; import { interactionReady, markWaitingForUser } from "./interaction.ts"; import { annotateRunSummary, applyRecoveryOutcome, autoInvestigate, checkRecovery, isInvestigationRun, type RecoveryCheck, type RecoveryOutcome, recoveryActions, } from "./investigate.ts"; import { buildGoalReport } from "./report.ts"; import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts"; import { finalize } from "./service.ts"; import { awaitReady, catchUpChildren, claimSubagentSlot, setAwaitWake } from "./subagents.ts"; // 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报) // 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。 const id = z.string().uuid(); /** * agent.await / agent.spawn{await:true} 挂起时的兜底轮询间隔(正常由子会话落定时的 wakeParent 信号立即唤醒; * 信号在等待开始前送达而丢失时靠它兜底)与单个会话最多挂起次数 */ const AWAIT_POLL_SEC = 10; const MAX_AWAIT_SUSPENDS = 100; const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const; export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) { registry.register( defineTask({ name: "runAgent", version: 1, lane: "agent", /** upstreamId:覆盖默认上游(W7-A);缺省用 agent_run.upstream_id,再缺省 settings.pi.defaultUpstreamId */ inputSchema: z.object({ agentRunId: id, upstreamId: id.optional() }), retryPolicy: DB_RETRY, handler: async (t) => { const e = env(); const runId = t.input.agentRunId; const upstreamId = t.input.upstreamId; await t.step.run("start", async () => { if (upstreamId) await e.ctx.db .updateTable("agent_run") .set({ upstream_id: upstreamId }) .where("id", "=", runId) .where("started_at", "is", null) .execute(); await e.ctx.db .updateTable("task") .set({ status: "running", started_at: new Date(), attempts: sql`attempts + 1` }) .where("subject_type", "=", "agent_run") .where("subject_id", "=", runId) .execute(); return null; }); // 子代理:先占并发名额(父会话下同时 running 的子会话 < maxConcurrent);没名额就挂起等待,不占 worker、不占模型 const parentId = await t.step.run("subagent-parent", async () => { const r = await e.ctx.db .selectFrom("agent_run") .select("parent_run_id") .where("id", "=", runId) .executeTakeFirst(); return r?.parent_run_id ?? null; }); let res: DriveResult; const slot = parentId ? await t.step.waitForFact<"claimed" | "cancelled">("subagent-slot", { check: () => claimSubagentSlot(e.ctx, runId), timeout: "1s", maxRounds: 3600, }) : "claimed"; if (slot === null) res = { status: "failed", reason: "error", budget: null, message: "等待子会话名额超时", finalText: "" }; else { res = await t.step.run("drive", () => drive(e, e.ctx.capabilities, runId, t.abortSignal)); // agent.await / agent.spawn{await:true} 挂起:会话已关闭;登记唤醒信号后 waitForFact 等子会话落定 / 超时 / 取消 // (不占 worker 槽位、不调用模型),子会话落定时写 fact 唤醒;再重开会话 resume(挂起的调用重放一次拿到结果) const resumedAwaits: string[] = []; for (let n = 1; res.status === "suspended" && res.suspension; n++) { const sus = res.suspension; if (n > MAX_AWAIT_SUSPENDS) { res = { status: "failed", reason: "error", budget: null, message: "等待子会话次数过多", finalText: "" }; break; } const waitMs = Math.max(0, Date.parse(sus.deadline) - Date.now()); // 等用户(方案选择 / 审批)与等子会话共用挂起机制;步骤名保持 subagent-await-N 以兼容已有运行的步骤历史 const user = sus.kind === "options" || sus.kind === "approval"; const fact = `subagent-await-${n}`; const engineRunId = t.runId; await t.step.run(`${fact}-arm`, async () => { await setAwaitWake(e.ctx, runId, { engineRunId, fact }); if (user) await markWaitingForUser(e.ctx, runId, sus); return null; }); await t.step.waitForFact(fact, { check: () => (user ? interactionReady(e.ctx, runId, sus) : awaitReady(e.ctx, runId, sus)), timeout: `${AWAIT_POLL_SEC}s`, maxRounds: Math.max(1, Math.ceil(waitMs / 1000 / AWAIT_POLL_SEC) + 1), }); await t.step.run(`${fact}-disarm`, () => setAwaitWake(e.ctx, runId, null)); resumedAwaits.push(sus.invocationId); const resumed = [...resumedAwaits]; res = await t.step.run(`drive-${n}`, () => drive(e, e.ctx.capabilities, runId, t.abortSignal, { resumedAwaits: resumed }), ); } } let settled: AgentGoalReport | null = null; const run = await e.ctx.db .selectFrom("agent_run") .select(["mode", "dedupe_key"]) .where("id", "=", runId) .executeTakeFirstOrThrow(); // 后台授权内恢复:核实业务事实(下载 / 任务 / 身份状态)后再收尾;不等"目标入库" const recovering = run.mode === "execute" && isInvestigationRun(run); let recovery: RecoveryOutcome | null = null; if (recovering) { const acts = await t.step.run("recover-actions", () => recoveryActions(e.ctx, runId)); let check: RecoveryCheck | null = null; if (res.status === "completed" && acts.actions.some((a) => a.status === "succeeded")) { const s = await e.ctx.settings.get("agent"); const pollSec = Math.min(e.opts.goalPollSec ?? s.goalPollSec, 30); check = await t.step.waitForFact("recover-verified", { check: async () => { const c = await checkRecovery(e.ctx, acts.actions); return c.state === "pending" ? null : c; }, timeout: `${pollSec}s`, maxRounds: Math.max(1, Math.min(900, Math.ceil(s.recoverVerifySec / pollSec))), }); // 超时:用最后一次观测作为证据(state 仍是 pending → unverified) check ??= await t.step.run("recover-last-check", () => checkRecovery(e.ctx, acts.actions)); } recovery = await t.step.run("recover-outcome", () => applyRecoveryOutcome(e.ctx, runId, res, acts, check)); } else if (res.status === "completed" && run.mode === "execute") { const s = await e.ctx.settings.get("agent"); const pollSec = e.opts.goalPollSec ?? s.goalPollSec; const rounds = Math.max(1, Math.min(900, Math.ceil((s.goalWaitHours * 3600) / pollSec))); settled = await t.step.waitForFact("goal-settled", { check: async () => { const c = await e.ctx.db .selectFrom("agent_run") .select("cancel_requested_at") .where("id", "=", runId) .executeTakeFirst(); // 已请求取消:不再等待,finalize 按取消收尾 if (c?.cancel_requested_at) return buildGoalReport(e.ctx.db, runId, res.finalText || null); return goalSettled(e, runId, res.finalText); }, timeout: `${pollSec}s`, maxRounds: rounds, }); } const out = await t.step.run("finalize", () => finalize(e, runId, res, settled, recovery ? { summaryPrefix: recovery.summary } : {}), ); // 父会话收尾后才结束的子会话:补记进父事件流 await t.step.run("subagent-catchup", () => catchUpChildren(e.ctx, runId)); if (recovery) { // 已在 finalize 内带上核实结论;保留该步骤兼容旧 run 的步骤历史(已带前缀时幂等跳过) const rec = recovery; return t.step.run("recover-summary", () => annotateRunSummary(e.ctx, runId, rec)); } return out; }, }), defineTask({ name: "resolveDecision", version: 1, lane: "agent", // identify:用户在整理记录里点「让智能体识别」(整理没识别出作品的事项,另起一个识别会话) inputSchema: z.object({ decisionId: id, trigger: z.enum(["created", "updated", "identify"]).default("updated") }), retryPolicy: DB_RETRY, handler: async (t) => { const e = env(); if (t.input.trigger === "created") return t.step.run("investigate", () => autoInvestigate(e.ctx, t.input.decisionId)); if (t.input.trigger === "identify") return t.step.run("identify", () => autoInvestigate(e.ctx, t.input.decisionId, { manual: true })); return t.step.run("execute", () => executeResolution(e.ctx, t.input.decisionId)); }, }), ); } /** * 执行 decision 的处理结果(身份类由 sources-identity 在处理端点里直接执行,这里跳过): * - 选项带 capabilityId → 经注册中心调用(仍走授权/审计/账本); * - retry → 重试关联任务;reevaluate / accept → 按对象重发 evaluateAcquisition;dismiss / ignore → 无动作。 */ export async function executeResolution(ctx: ServerContext, decisionId: string) { const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); if (!d) return { executed: false, reason: "not_found" }; if (d.status !== "resolved") return { executed: false, reason: `status_${d.status}` }; if (d.kind === "identity_ambiguous" || d.kind === "identity_unresolved") return { executed: false, reason: "handled_by_sources_identity" }; const res = (d.resolution ?? {}) as { optionKey?: string | null; by?: string; execution?: unknown }; if (res.execution) return { executed: false, reason: "already_executed" }; const key = res.optionKey ?? null; const opt = ((d.options ?? []) as DecisionOption[]).find((o) => o.key === key); const by = d.resolved_by ?? res.by ?? "system"; let execution: Record; try { if (opt?.capabilityId) { const output = await ctx.capabilities.invoke(opt.capabilityId, opt.input ?? {}, { actorId: by.replace(/^admin:/, "user:"), taskId: `decision:${d.id}`, scopeIds: [], signal: new AbortController().signal, // 人工在待处理中心选中的处理 = 控制台人工授权;系统/Agent 关闭的事项仍需授权记录 source: /^(admin|user):/.test(by) ? "web" : "task", invocationId: `decision:${d.id}`, }); execution = { kind: "capability", capabilityId: opt.capabilityId, output }; } else if (key === "retry" && d.task_id) { const row = await retryTask(ctx, d.task_id, by, `decision ${d.id}`); execution = { kind: "retry", taskId: row.id, status: row.status }; } else if ((key === "reevaluate" || key === "accept") && d.subject_id) { const input = d.subject_type === "acquisition_slot" ? { acquisitionSlotId: d.subject_id } : d.subject_type === "source_release" ? { sourceReleaseId: d.subject_id } : d.subject_type === "acquisition_plan" ? { acquisitionSlotId: ( await ctx.db .selectFrom("acquisition_plan") .select("slot_id") .where("id", "=", d.subject_id) .executeTakeFirst() )?.slot_id, } : null; if (!input || Object.values(input).some((v) => !v)) execution = { kind: "none", reason: "subject_not_evaluable" }; else { const { runId } = await ctx.engine.enqueue("evaluateAcquisition", input, { idempotencyKey: `decision:${d.id}:reevaluate`, traceId: d.id, }); execution = { kind: "reevaluate", engineRunId: runId, input }; } } else { execution = { kind: "none", reason: key ? `option_${key}` : "no_option" }; } } catch (err) { execution = { kind: "error", message: (err as Error).message.slice(0, 500) }; } await ctx.db .updateTable("decision") .set({ resolution: sql`coalesce(resolution, '{}'::jsonb) || ${JSON.stringify({ execution })}::jsonb` }) .where("id", "=", d.id) .execute(); return { executed: execution.kind !== "none" && execution.kind !== "error", execution }; } export const agentOutboxRoutes: Record = { "decision.created": (ev) => { const p = ev.payload as EventPayload<"decision.created">; if ( !["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"].includes(p.kind) && !p.dedupeKey?.startsWith("release_facts:") ) return null; return { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "created" } }; }, "decision.updated": (ev) => { const p = ev.payload as EventPayload<"decision.updated">; return p.status === "resolved" ? { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "updated" } } : null; }, };