283 lines
14 KiB
TypeScript
283 lines
14 KiB
TypeScript
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<true>(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<RecoveryCheck>("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<AgentGoalReport>("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<string, unknown>;
|
|
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<string, OutboxRouteFn> = {
|
|
"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;
|
|
},
|
|
};
|