feat(智能体): 不设硬性预算上限,花费改为可见

- 去掉每天次数、每天花费、单次花费、单次 token 四个上限的拦截;设置项登记为弃用,旧值兼容读取、不生效
- 保留单次工具调用次数与时长两个防跑飞限制,缺省调宽(120 次 / 1800 秒,后台调查 20 次)
- 新增 GET /agent/usage:今天与最近 7 天按会话类型(对话 / 巡检 / 后台调查 / 整理识别 / 子会话)的次数、token 与费用
- 设置 agent.autoIdentifyOrganize(整理识别不到时叫智能体识别,缺省开)一并加在智能体设置里
- runner 里加整理识别会话的能力白名单与调查会话去重键解析(investigationDecisionId)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 16:47:18 +08:00
1 parent 7b7fadbfb6
commit 414ea7c890
10 files changed
+259 -48

No files matched your search

+1 -2
View File
@@ -78,9 +78,8 @@ export function systemChallengeVision(env: () => AgentEnv, registry: CapabilityR
success: { textAbsent: req.textAbsent ?? "Just a moment" },
maxSteps: Math.min(12, Math.max(1, req.maxSteps)),
budget: {
// 不设 token / 费用上限(2026-10-06);步数与时长是防跑飞限制
maxDurationSec: Math.min(120, s.agent.maxDurationSec),
...(s.agent.maxTokensPerRun ? { maxTokens: Math.max(1000, s.agent.maxTokensPerRun) } : {}),
...(s.agent.maxCostUsdPerRun ? { maxCostUsd: s.agent.maxCostUsdPerRun } : {}),
},
};
const hctx = {
+3
View File
@@ -19,6 +19,7 @@ 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、持续授权 / 单次审批、能力目录与调用、业务任务。
@@ -119,6 +120,8 @@ export function registerAgentRoutes(
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);
+27 -18
View File
@@ -91,12 +91,35 @@ export const RECOVER_CAPABILITIES = [
"download.recheck",
"episode_mapping.apply_rule",
] as const;
/**
* 整理没识别出作品时的智能体识别:查作品(TMDB / 媒体库 / 网页)+ 指定作品并重新整理。
* 不能下载、删除、改别的事项;organize.assign_identity 只放行本事项(authorize.ts)
*/
export const ORGANIZE_IDENTIFY_CAPABILITIES = [
"catalog.lookup",
"inventory.query",
"release.inspect",
"download.inspect",
"task.inspect",
"decision.list",
"web.search",
"web.fetch",
"organize.assign_identity",
] as const;
/**
* 后台调查会话的去重键 investigate:<事项 id>;用户在整理记录里再点一次「让智能体识别」时加 #<序号> 另起一个会话。
* 返回事项 id,不是调查会话返回 null
*/
export function investigationDecisionId(dedupeKey: string | null | undefined): string | null {
return dedupeKey?.startsWith("investigate:") ? (dedupeKey.slice("investigate:".length).split("#")[0] ?? null) : null;
}
/** 被视为"恢复动作"的能力(结果需系统核实) */
export const RECOVERY_ACTIONS = [
"task.retry",
"download.recheck",
"identity.resolve",
"episode_mapping.apply_rule",
"organize.assign_identity",
] as const;
export interface RunScope {
@@ -162,8 +185,9 @@ export function agentSettings(
upstream: RunUpstream,
): AgentSettings {
const pi = s.pi;
const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined;
const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined;
// 设置里不再有 token / 费用上限(2026-10-06);只有调用方显式划拨(子会话、巡检预算)时才限制
const maxTokens = budget.maxTokens;
const maxCost = budget.maxCostUsd;
const up = upstream.row;
return {
provider: up.provider,
@@ -387,20 +411,6 @@ async function lastOutcomeEvent(env: AgentEnv, runId: string) {
return r ?? null;
}
async function dailyBudgetBlock(env: AgentEnv, s: GlobalSettings, run: AgentRunRow): Promise<string | null> {
if (run.started_at) return null; // 续跑不重复计数
const day = new Date();
day.setHours(0, 0, 0, 0);
const r = await sql<{ n: number; cost: number | null }>`
select count(*)::int as n, sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost
from agent_run where started_at >= ${day} and id <> ${run.id}`.execute(env.ctx.db);
const n = r.rows[0]?.n ?? 0;
const cost = r.rows[0]?.cost ?? 0;
if (n >= s.budget.agentMaxRunsPerDay) return `今日智能体运行次数已达上限 ${s.budget.agentMaxRunsPerDay}`;
if (cost >= s.budget.agentMaxCostUsdPerDay) return `今日智能体费用已达上限 $${s.budget.agentMaxCostUsdPerDay}`;
return null;
}
/**
* 驱动一次模型会话直到本轮结束(含追加消息)。整个函数在一个持久步骤里执行:worker 崩溃后新 worker 从头重跑本函数,
* 打开同一 JSONL 会话,pendingOperations>0 时对账 + resume,已完成的工具调用不会重放。
@@ -416,9 +426,8 @@ export async function drive(
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
if (run.cancel_requested_at || signal.aborted)
return { status: "cancelled", reason: "aborted", budget: null, message: "运行已被取消", finalText: "" };
// 不设每日次数 / 费用上限(2026-10-06):花费在设置页与运行状态里可见,单次运行只受工具调用次数与时长约束
const s = await ctx.settings.all();
const blocked = await dailyBudgetBlock(env, s, run);
if (blocked) return { status: "failed", reason: "budget", budget: "daily", message: blocked, finalText: "" };
const scope = (run.scope ?? {}) as unknown as RunScope;
// 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId;都没有直接失败
+3 -3
View File
@@ -471,9 +471,9 @@ function limitsOf(run: AgentRunRow, s: GlobalSettings): Limits {
const b = (run.budget ?? {}) as AgentBudget;
return {
maxToolCalls: b.maxToolCalls ?? s.agent.maxToolCalls,
maxTokens: b.maxTokens ?? s.agent.maxTokensPerRun ?? null,
maxCostUsd:
b.maxCostUsd ?? (s.agent.maxCostUsdPerRun && s.agent.maxCostUsdPerRun > 0 ? s.agent.maxCostUsdPerRun : null),
// token / 费用只认本会话显式划拨的额度;设置里不再有全局上限(2026-10-06)
maxTokens: b.maxTokens ?? null,
maxCostUsd: b.maxCostUsd && b.maxCostUsd > 0 ? b.maxCostUsd : null,
maxDurationSec: b.maxDurationSec ?? s.agent.maxDurationSec,
};
}
+44
View File
@@ -0,0 +1,44 @@
import type { AgentSpend, AgentSpendBucket, AgentSpendKind } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
// 智能体用量(2026-10-06):不设每日次数 / 费用上限,改为把花费摆出来。按 agent_run 的开始时间(没开始的按创建时间)
// 统计今天与最近 7 天,按会话类型分组;费用取 usage.costUsd(上游不报费用时为 0)。
/** 整理没识别出作品时智能体识别的会话发起者(见 investigate.ts) */
export const ORGANIZE_IDENTIFY_ACTOR = "system:organize-identify";
const KIND_SQL = sql<AgentSpendKind>`case
when parent_run_id is not null then 'subagent'
when created_by = ${ORGANIZE_IDENTIFY_ACTOR} then 'organize_identify'
when trigger = 'schedule' then 'patrol'
when trigger = 'event' then 'investigate'
else 'chat' end`;
const ORDER: AgentSpendKind[] = ["chat", "patrol", "investigate", "organize_identify", "subagent"];
async function bucket(db: Kysely<DB>, since: Date): Promise<AgentSpendBucket> {
const r = await sql<{ kind: AgentSpendKind; runs: number; tokens: string | null; cost: number | null }>`
select ${KIND_SQL} as kind, count(*)::int as runs,
sum(coalesce((usage->>'totalTokens')::bigint, 0))::bigint as tokens,
sum(coalesce((usage->>'costUsd')::float8, 0))::float8 as cost
from agent_run
where coalesce(started_at, created_at) >= ${since}
group by 1`.execute(db);
const byKind = r.rows
.map((x) => ({ kind: x.kind, runs: x.runs, tokens: Number(x.tokens ?? 0), costUsd: x.cost ?? 0 }))
.sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind));
return {
runs: byKind.reduce((a, x) => a + x.runs, 0),
tokens: byKind.reduce((a, x) => a + x.tokens, 0),
costUsd: byKind.reduce((a, x) => a + x.costUsd, 0),
byKind,
};
}
export async function agentUsage(db: Kysely<DB>, now = new Date()): Promise<AgentSpend> {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const week = new Date(today.getTime() - 6 * 86_400_000);
const [t, w] = await Promise.all([bucket(db, today), bucket(db, week)]);
return { today: t, last7d: w };
}
@@ -0,0 +1,110 @@
import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings } from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { agentSettings } from "../../src/modules/agent/runner.ts";
import { ORGANIZE_IDENTIFY_ACTOR } from "../../src/modules/agent/usage.ts";
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// 智能体不设硬性预算上限(2026-10-06):每天次数 / 每天花费 / 单次花费 / 单次 token 四个上限不再拦截(旧值兼容读取、不生效),
// 只保留单次工具调用次数与单次最长时长两个防跑飞限制(缺省调宽);花费改为可见:/agent/usage 按会话类型统计今天与最近 7 天。
const upstream = { row: { provider: "fake", default_model: null, thinking_level: "off" } as unknown as PiUpstreamRow };
describe("预算上限移除,防跑飞限制保留", () => {
it("旧设置里的 token / 费用上限不进运行参数;工具调用次数与时长照用", () => {
const legacy = {
...DEFAULT_SETTINGS,
agent: { ...DEFAULT_SETTINGS.agent, maxTokensPerRun: 1000, maxCostUsdPerRun: 0.01 },
budget: { ...DEFAULT_SETTINGS.budget, agentMaxRunsPerDay: 0, agentMaxCostUsdPerDay: 0 },
} as GlobalSettings;
// biome-ignore lint/suspicious/noExplicitAny: 只取用到的上游字段
const s = agentSettings(legacy, {}, true, upstream as any);
expect(s).not.toHaveProperty("costBudget");
expect(s.maxToolCalls).toBe(DEFAULT_SETTINGS.agent.maxToolCalls);
expect(s.maxDurationMs).toBe(DEFAULT_SETTINGS.agent.maxDurationSec * 1000);
// 调用方显式划拨(子会话、巡检预算)仍然生效
// biome-ignore lint/suspicious/noExplicitAny: 同上
const sub = agentSettings(legacy, { maxToolCalls: 3, maxCostUsd: 0.5 }, true, upstream as any);
expect(sub.maxToolCalls).toBe(3);
expect(sub.costBudget).toEqual({ maxCostUsd: 0.5 });
});
it("缺省调宽;四个上限登记为弃用字段", () => {
expect(DEFAULT_SETTINGS.agent.maxToolCalls).toBeGreaterThanOrEqual(100);
expect(DEFAULT_SETTINGS.agent.maxDurationSec).toBeGreaterThanOrEqual(1800);
expect(DEFAULT_SETTINGS.agent.investigateMaxToolCalls).toBeGreaterThanOrEqual(20);
expect(DEFAULT_SETTINGS.agent.autoIdentifyOrganize).toBe(true);
expect(DEFAULT_SETTINGS.budget).not.toHaveProperty("agentMaxRunsPerDay");
expect(DEPRECATED_SETTINGS_FIELDS).toEqual(
expect.arrayContaining([
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
"agent.maxTokensPerRun",
"agent.maxCostUsdPerRun",
]),
);
});
});
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("花费统计 /agent/usage(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness({ extraModules: [createAgentModule()] });
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("今天与最近 7 天按会话类型统计次数、token 与费用;超过 7 天的不算", async () => {
const day = 86_400_000;
const now = Date.now();
const run = async (
trigger: string,
at: number,
usage: { totalTokens: number; costUsd: number },
extra: Record<string, unknown> = {},
) =>
(
await h.db
.insertInto("agent_run")
.values({
trigger,
goal: "g",
status: "succeeded",
mode: "execute",
usage: JSON.stringify(usage),
started_at: new Date(at),
created_at: new Date(at),
...extra,
} as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
const chat = await run("user", now, { totalTokens: 1000, costUsd: 0.1 });
await run("subagent", now, { totalTokens: 500, costUsd: 0.05 }, { parent_run_id: chat });
await run("event", now, { totalTokens: 200, costUsd: 0.02 }, { created_by: ORGANIZE_IDENTIFY_ACTOR });
await run("event", now - 3 * day, { totalTokens: 300, costUsd: 0.03 });
await run("schedule", now - 3 * day, { totalTokens: 400, costUsd: 0.04 });
await run("user", now - 10 * day, { totalTokens: 9999, costUsd: 9 });
const res = await h.app.request("/api/v1/agent/usage", { headers: { authorization: `Bearer ${DEV_KEY}` } });
expect(res.status).toBe(200);
const { today, last7d } = (await json(res)).data;
expect(today.runs).toBe(3);
expect(today.tokens).toBe(1700);
expect(today.costUsd).toBeCloseTo(0.17);
expect(today.byKind.map((k: { kind: string }) => k.kind)).toEqual(["chat", "organize_identify", "subagent"]);
expect(last7d.runs).toBe(5);
expect(last7d.costUsd).toBeCloseTo(0.24);
expect(last7d.byKind).toEqual(
expect.arrayContaining([
{ kind: "patrol", runs: 1, tokens: 400, costUsd: 0.04 },
{ kind: "investigate", runs: 1, tokens: 300, costUsd: 0.03 },
]),
);
});
});