feat(agent): budget reminders, usage while waiting, budget-exceeded status and dedupe

- tool results carry a budget reminder near limits; text before the last tool
  call is dropped as report when cut by budget/abort
- usage persisted when a run suspends, so waiting sessions show spent tokens
- task page shows budget_exceeded via business.outcome instead of completed
- budget-exceeded decisions merge by root cause (decision / schedule / goal
  template); new summaries added to investigate allowlist

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:58:28 +08:00
1 parent 4cd9ec8390
commit a00a9eaf5d
14 files changed
+205 -21

No files matched your search

+18 -1
View File
@@ -59,6 +59,9 @@ export const INVESTIGATE_CAPABILITIES = [
"release.inspect",
"inventory.query",
"task.inspect",
"download.inspect",
"decision.list",
"subscription.list",
"identity.resolve",
] as const;
/**
@@ -266,7 +269,8 @@ function systemPromptFor(
if (sub.canSpawn)
constraints.push(
`可以用 agent.spawn 把彼此独立的有界子目标交给子会话(同时最多 ${sub.maxConcurrent} 个,派生层数上限 ${sub.maxDepth});` +
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;派生后用 agent.await 等结果再汇总,不要为小事派生。",
"子会话的能力、模式与预算都不会超过本会话,且会占用本会话预算;budget 的每一项都必须小于本会话剩余预算(超过会被拒绝,不给则按剩余的一半划拨);" +
"派生后用 agent.await 等结果再汇总,不要为小事派生。",
);
if (scope.capabilities.includes("web.search"))
constraints.push(
@@ -506,6 +510,8 @@ export async function drive(
} finally {
clearInterval(watch);
signal.removeEventListener("abort", abortOnce);
// 挂起等待(审批 / 选方案 / 子会话)时没有 run_completed 事件:把已用累计写回,等待期间界面照样显示 token / 费用
await persistUsage(ctx, runId, handle.stats().usage).catch(() => undefined);
await liveRun.stop();
await handle.close();
}
@@ -543,6 +549,17 @@ export async function drive(
}
}
/** 用量累计写回 agent_run.usage:按字段取较大值(重开会话后运行时计数可能从 0 开始,不能倒退) */
export async function persistUsage(ctx: ServerContext, runId: string, usage: Record<string, unknown>) {
const nums = Object.fromEntries(Object.entries(usage).filter(([, v]) => typeof v === "number" && v > 0));
if (Object.keys(nums).length === 0) return;
await sql`
update agent_run set usage = coalesce(usage, '{}'::jsonb) || (
select coalesce(jsonb_object_agg(k, greatest(v::float8, coalesce((usage->>k)::float8, 0))), '{}'::jsonb)
from jsonb_each_text(${JSON.stringify(nums)}::jsonb) as n(k, v))
where id = ${runId}`.execute(ctx.db);
}
/** 等待目标关联的下载/入库落定:返回报告(state≠waiting)或 null(继续等) */
export async function goalSettled(env: AgentEnv, runId: string, modelText: string): Promise<AgentGoalReport | null> {
const r = await buildGoalReport(env.ctx.db, runId, modelText);
+38 -3
View File
@@ -209,7 +209,15 @@ async function writeDecision(
values (${d.kind}, 'warning', ${d.title}, ${d.summary}, 'agent_run', ${d.subjectId}, ${d.dedupeKey},
${JSON.stringify(d.evidence)}::jsonb, ${d.subjectId})
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, updated_at = now()
do update set attempts = decision.attempts + 1, summary = excluded.summary,
agent_run_id = excluded.agent_run_id, updated_at = now(),
-- 同根因合并:保留历次 run id(最多 50 个),其余证据取最新
evidence = decision.evidence || excluded.evidence || jsonb_build_object('runIds',
(select coalesce(jsonb_agg(u.x), '[]'::jsonb) from (
select v.x from (
select jsonb_array_elements(coalesce(decision.evidence->'runIds', '[]'::jsonb))
union select to_jsonb(${d.subjectId}::text)) v(x)
limit 50) u))
returning id, (xmax = 0) as inserted`.execute(trx);
const row = r.rows[0]!;
if (row.inserted)
@@ -222,6 +230,32 @@ async function writeDecision(
});
}
/** 目标模板归一化:去掉时间戳、UUID、数字与空白,同一模板渲染出的目标得到同一个键 */
export function goalTemplateKey(goal: string): string {
const norm = goal
.replace(/\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?/g, "<t>")
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
.replace(/\d+/g, "<n>")
.replace(/\s+/g, "")
.toLowerCase();
return sha(norm).slice(0, 16);
}
/**
* 超预算待处理事项的去重键(按根因):
* 后台调查 → 被调查事项的去重键(同一下载 / 同一身份的新事项也算同根因);定时巡检 → 定时任务;
* 其余 → 目标模板。
*/
export async function budgetDedupeKey(ctx: ServerContext, run: AgentRunRow): Promise<string> {
if (run.dedupe_key?.startsWith("investigate:")) {
const id = run.dedupe_key.slice("investigate:".length);
const d = await ctx.db.selectFrom("decision").select("dedupe_key").where("id", "=", id).executeTakeFirst();
return `agent_budget:root:${d?.dedupe_key ?? `decision:${id}`}`;
}
if (run.created_by?.startsWith("schedule:")) return `agent_budget:${run.created_by}`;
return `agent_budget:goal:${goalTemplateKey(run.goal)}`;
}
/** 终态:按业务表汇总的报告决定状态;预算超限 → budget_exceeded + 待处理;调查运行回写 decision */
export async function finalize(
env: AgentEnv,
@@ -316,13 +350,14 @@ export async function finalize(
.execute();
}
if (status === "budget_exceeded") {
// 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建
await writeDecision(ctx, {
kind: "other",
title: "Agent 运行超出预算",
summary: res.message ?? "超出预算",
subjectId: runId,
dedupeKey: `agent_budget:${runId}`,
evidence: { runId, budget: res.budget, message: res.message, goal: run.goal },
dedupeKey: await budgetDedupeKey(ctx, run),
evidence: { runId, runIds: [runId], budget: res.budget, message: res.message, goal: run.goal },
});
}
// 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒)
@@ -218,10 +218,12 @@ export async function resolveBusiness(
}),
);
const agentIds = [...(byKind.get("agent") ?? [])];
const agents = new Map<string, { trigger: string; created_by: string | null }>();
const agents = new Map<string, { trigger: string; created_by: string | null; status: string }>();
if (agentIds.length) {
const r = await sql<{ id: string; trigger: string; created_by: string | null }>`
select id::text as id, trigger, created_by from agent_run where id::text = any(${agentIds}::text[])`.execute(db);
const r = await sql<{ id: string; trigger: string; created_by: string | null; status: string }>`
select id::text as id, trigger, created_by, status from agent_run where id::text = any(${agentIds}::text[])`.execute(
db,
);
for (const a of r.rows) agents.set(a.id, a);
}
@@ -240,6 +242,7 @@ export async function resolveBusiness(
agentCreatedBy: agent?.created_by ?? null,
fromOutbox: !!r.idempotency_key && outboxKeys.has(r.idempotency_key),
}),
...(agent ? { outcome: agent.status } : {}),
});
}
return out;