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:
1 parent
4cd9ec8390
commit
a00a9eaf5d
14 files changed
+205
-21
No files matched your search
@@ -202,8 +202,10 @@ function toolResults(messages: Message[]): FakeToolResult[] {
|
||||
if (m.role !== "toolResult") continue;
|
||||
const text = messageText(m);
|
||||
let json: unknown;
|
||||
// 第一块文本是能力输出;运行时可能在后面追加【预算提醒】等附注块
|
||||
const first = (m.content as Array<{ type: string; text?: string }>).find((c) => c.type === "text")?.text ?? text;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
json = JSON.parse(first);
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ class PiAgentRun implements AgentRunHandle {
|
||||
private toolCalls = 0;
|
||||
private usage: AgentUsageTotals = emptyUsage();
|
||||
private finalText = "";
|
||||
/** finalText 之后没有再调用工具(是收尾文字而不是"接下来我要…"的中间过程) */
|
||||
private finalIsTail = false;
|
||||
private budget: BudgetState | undefined;
|
||||
private userAbort: string | undefined;
|
||||
private startedAt: number | undefined;
|
||||
@@ -201,12 +203,19 @@ class PiAgentRun implements AgentRunHandle {
|
||||
agentRunId: this.runId,
|
||||
modelImages: this.supportsImages,
|
||||
});
|
||||
return { content: toolContent(output, this.supportsImages), details: undefined };
|
||||
const content = toolContent(output, this.supportsImages);
|
||||
const notice = this.budgetNotice();
|
||||
if (notice) content.push({ type: "text", text: notice });
|
||||
return { content, details: undefined };
|
||||
} catch (error) {
|
||||
const notice = this.budgetNotice();
|
||||
const tail = notice ? `\n${notice}` : "";
|
||||
if (isCapabilityError(error)) {
|
||||
throw new Error(JSON.stringify({ error: { code: error.code, message: redactString(error.message) } }));
|
||||
throw new Error(
|
||||
JSON.stringify({ error: { code: error.code, message: redactString(error.message) } }) + tail,
|
||||
);
|
||||
}
|
||||
throw new Error(redactString(error instanceof Error ? error.message : String(error)));
|
||||
throw new Error(redactString(error instanceof Error ? error.message : String(error)) + tail);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -258,9 +267,13 @@ class PiAgentRun implements AgentRunHandle {
|
||||
});
|
||||
events.on("message_end", (e) => {
|
||||
const text = assistantText(e.message);
|
||||
if (text?.trim()) this.finalText = text;
|
||||
if (text?.trim()) {
|
||||
this.finalText = text;
|
||||
this.finalIsTail = true;
|
||||
}
|
||||
});
|
||||
events.on("tool_start", (e) => {
|
||||
this.finalIsTail = false;
|
||||
if (this.rejected.has(e.toolCallId)) return;
|
||||
const capabilityId = this.allowed.get(e.toolName);
|
||||
if (!capabilityId) {
|
||||
@@ -408,7 +421,8 @@ class PiAgentRun implements AgentRunHandle {
|
||||
status: "failed",
|
||||
reason,
|
||||
message,
|
||||
finalText: this.finalText,
|
||||
// 被预算 / 中止打断时,工具调用之前的文字只是中间过程("我再查一下…"),不作为汇报
|
||||
finalText: (reason === "budget" || reason === "aborted") && !this.finalIsTail ? "" : this.finalText,
|
||||
usage: this.usage,
|
||||
...(budget ? { budget } : {}),
|
||||
};
|
||||
@@ -435,6 +449,28 @@ class PiAgentRun implements AgentRunHandle {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算将尽提醒(AG-3):工具调用只剩 ≤2 次,或 token / 费用 / 时长用到 85%,在工具结果末尾追加一句,
|
||||
* 让模型停止探查、先写中文小结,避免被硬截断后只剩中间过程。
|
||||
*/
|
||||
private budgetNotice(): string | null {
|
||||
const out: string[] = [];
|
||||
const ext = this.spec.externalUsage?.();
|
||||
const left = this.settings.maxToolCalls - this.toolCalls - (ext?.toolCalls ?? 0);
|
||||
if (left <= 2) out.push(`工具调用只剩 ${Math.max(0, left)} 次`);
|
||||
const cost = this.settings.costBudget;
|
||||
const tokens = this.usage.totalTokens + (ext?.totalTokens ?? 0);
|
||||
const usd = this.usage.costUsd + (ext?.costUsd ?? 0);
|
||||
if (cost?.maxTotalTokens !== undefined && tokens >= cost.maxTotalTokens * 0.85)
|
||||
out.push(`token 已用 ${tokens}/${cost.maxTotalTokens}`);
|
||||
if (cost?.maxCostUsd !== undefined && usd >= cost.maxCostUsd * 0.85)
|
||||
out.push(`费用已用 $${usd.toFixed(4)}/$${cost.maxCostUsd}`);
|
||||
if (this.startedAt !== undefined && Date.now() - this.startedAt >= this.settings.maxDurationMs * 0.85)
|
||||
out.push("运行时长接近上限");
|
||||
if (out.length === 0) return null;
|
||||
return `【预算提醒】${out.join(";")}。停止探查,下一条回复直接用中文写小结(已完成什么、证据、还缺什么)。`;
|
||||
}
|
||||
|
||||
/** token / 费用预算:自身用量 + 子会话用量(externalUsage) */
|
||||
private checkCost(): void {
|
||||
const cost = this.settings.costBudget;
|
||||
|
||||
@@ -270,6 +270,34 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
expect(fx.audits.filter((a) => a.capabilityId === "inventory.query")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("预算将尽:工具结果追加【预算提醒】;被预算截断时中间过程文字不作为汇报", async () => {
|
||||
const fx = makeFixture();
|
||||
const loop: FakeScript = {
|
||||
match: "Dune",
|
||||
steps: Array.from({ length: 10 }, () => ({
|
||||
text: "Let me check the inventory again.",
|
||||
toolCalls: [{ capability: "inventory.query", input: { title: "Dune" } }],
|
||||
})),
|
||||
finalText: "不该到这里",
|
||||
};
|
||||
const { run, fake } = await start(fx, [loop], { settings: { maxToolCalls: 4 } });
|
||||
const out = await run.prompt();
|
||||
expect(out).toMatchObject({ status: "failed", reason: "budget", budget: "tool_calls", finalText: "" });
|
||||
expect(fake.lastRequest?.transcript).toContain("【预算提醒】工具调用只剩 2 次");
|
||||
});
|
||||
|
||||
it("预算:收尾文字在最后一次工具调用之后 → 被截断时仍保留为汇报", async () => {
|
||||
const fx = makeFixture();
|
||||
const script: FakeScript = {
|
||||
match: "Dune",
|
||||
steps: [{ toolCalls: [{ capability: "inventory.query", input: { title: "Dune" } }] }],
|
||||
finalText: "小结:库里没有 Dune。",
|
||||
};
|
||||
const { run } = await start(fx, [script], { settings: { maxToolCalls: 5 } });
|
||||
const out = await run.prompt();
|
||||
expect(out).toMatchObject({ status: "completed", finalText: "小结:库里没有 Dune。" });
|
||||
});
|
||||
|
||||
it("预算:运行时长超限 → run_failed(budget/duration)", async () => {
|
||||
const fx = makeFixture({ slowMs: 5_000 });
|
||||
fx.grants.set("user:1", new Set(["download.submit"]));
|
||||
|
||||
Reference in new issue
Block a user