Compare commits

...

4 Commits

Author SHA1 Message Date
Jesse_Chen da384357d0 fix(rectification): skip settlement for free turns
Staging Backend Quality Gate / validate (pull_request) Successful in 15m26s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 02:58:35 +08:00
jesse 863f65d090 Merge pull request #29: fix(rectification): pass server case id to agent
Staging Backend Quality Gate / validate (push) Successful in 11m48s
Staging Backend Quality Gate / publish (push) Successful in 8m11s
Pass the server-validated Case ID into V9 Agent messages so rectification tools do not guess an invalid case reference.
2026-08-12 02:19:59 +08:00
Jesse_Chen 32b65cfaf3 fix(rectification): pass server case id to agent
Staging Backend Quality Gate / validate (pull_request) Successful in 16m22s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 02:00:08 +08:00
jesse db9e7ba2a4 Merge pull request #28 from codex/fix-rectification-session-model-20260811
Staging Backend Quality Gate / validate (push) Successful in 11m38s
Staging Backend Quality Gate / publish (push) Successful in 7m18s
fix(rectification): bind default session model
2026-08-12 01:25:01 +08:00
5 changed files with 70 additions and 2 deletions
+31
View File
@@ -2914,3 +2914,34 @@
- 防复发:任何服务器端创建 `birth_time_rectification` Session 的路径都必须在同一事务内得到可解析的持久化模型与版本;前端显示的默认模型不能替代数据库绑定。
- 相关记录:BUG-060、BUG-163、BUG-168
- 修复版本:待提交
## BUG-170 | V9 Agent 开场未收到 Case ID 导致工具调用失败
- 状态:resolved(本地候选)
- 首次发现:2026-08-11
- 最近更新:2026-08-11
- 影响面:`POST /api/rectification/agent` 的 opening、message、read-only Agent 消息,以及所有要求 `caseId` 的 V9 rectification 工具调用。
- 用户现象:接口已返回 `200 application/x-ndjson`Skill 与 Case 均已加载,但回答正文报告 `invalid_case_id`,最后事件为 `run.failed`
- 触发条件:Agent 按指令调用 `rectification-read-case`,但服务端构造的 Agent 消息没有提供当前 Case ID。
- 根因:请求路由和 `runV9AgentTurn` 已验证 Case/Session 绑定,但 `buildAgentMessages` 只传时间与用户消息;模型只能猜测工具所需的 `caseId`
- 修复:在共享 Agent 消息构造处加入服务端已验证的唯一 Case ID,并明确所有 rectification 工具必须原样使用;不放宽 UUID、所有权或 Case/Session 绑定校验。
- 验证:新增回归测试捕获实际传给 Agent 的 opening 消息,要求包含精确服务端 Case ID;staging 需以原请求确认流以 `run.completed` 结束。
- 防复发:任何由模型调用、但值由服务端拥有的工具引用,都必须在 Agent 上下文中显式提供,不能要求模型猜测。
- 相关记录:BUG-163、BUG-169
- 修复版本:待提交
## BUG-171 | V9 免费开场生成成功后仍执行 usage settlement 导致 run.failed
- 状态:resolved(本地候选)
- 首次发现:2026-08-11
- 最近更新:2026-08-11
- 影响面:`POST /api/rectification/agent``opening``read_only` 免费 Turn,以及回答完成后的 Turn 最终状态与 assistant message 持久化。
- 用户现象:Agent 已加载 Skill 与正确 Case,并输出完整回答,但 NDJSON 最终事件仍为 `run.failed`;数据库 Turn 为 `retryable` 且没有持久化 assistant message。
- 触发条件:免费 `opening``read_only` Turn 正常生成回答并进入成功收尾。
- 根因:路由的 `billing.reserve()` 对免费 Turn 不创建 `usage_reservations`,但 `billing.complete()` 仍调用 `complete_usage`;数据库因找不到 reservation 返回 `request_missing`Agent runner 将已成功回答降级为 `usage_settlement_failed`
- 修复:免费 Turn 在 settlement adapter 中直接成功返回;只有 `message` Turn 才执行 reservation 与 settlement,保留原有付费消息的计费、幂等与失败保护。
- 验证:staging 数据库确认目标 Case 没有 usage reservation,直接调用同一结算函数稳定返回 `request_missing`;新增合同回归要求免费 Turn 同时绕过 reservation 与 settlement。部署后需以真实 opening 确认最终 `run.completed`、Turn `completed` 且 assistant message 已持久化。
- 防复发:任何声明为免费的 Agent action 必须在授权和结算两个阶段保持同一策略,不能只跳过预授权而继续结算。
- 相关记录:BUG-163、BUG-168、BUG-169、BUG-170
- 修复版本:待提交
@@ -209,7 +209,7 @@ export async function POST(request: Request) {
async reserve() {
// opening / read-only turns are free; the first substantive run
// reserves once, and resume/retry reuse the same case-bound request.
if (action === "opening" || action === "read_only") {
if (action !== "message") {
return { success: true, status: 200 };
}
try {
@@ -233,6 +233,7 @@ export async function POST(request: Request) {
}
},
async complete(usage) {
if (action !== "message") return true;
try {
const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId);
const settlement = await completeUsage(accounting, userId, billingRequestId, {
@@ -337,18 +337,20 @@ function buildAgentMessages(options: V9AgentRunOptions, _attempt: number): unkno
void _attempt;
const timeContext = options.timeContext
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
const caseContext = `【服务端 Case ID】${options.caseId}。所有 rectification 工具调用的 caseId 必须原样使用此值。`;
if (options.action === "opening") {
return [{
role: "user",
content: [
timeContext,
caseContext,
"【服务端开场指令】这是本校正 Case 的首次开场,还没有用户输入。请先调用 skill 工具加载 jyotish-birth-time-rectification,再调用 rectification-read-case 读取服务端 Case 与证据摘要,然后用简体中文自然开场:说明你会通过已发生的人生事件来校正出生时间,并自然地提出第一个最有用的问题(只需一个问题)。",
].join("\n"),
}];
}
return [{
role: "user",
content: [timeContext, options.message ?? ""].join("\n"),
content: [timeContext, caseContext, options.message ?? ""].join("\n"),
}];
}
@@ -21,6 +21,11 @@ test("Agentic rectification reuses one case-level usage authorization and the se
assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/);
});
test("free Agentic rectification turns bypass both usage reservation and settlement", () => {
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 2);
assert.match(rectificationRoute, /async complete\(usage\) \{\s*if \(action !== "message"\) return true;/);
});
test("standard consultation resolves and settles the session-pinned model version", () => {
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
@@ -173,6 +173,35 @@ function runOptions(overrides: Partial<V9AgentRunOptions> = {}): {
return { options: optionsValue, emitted, billing };
}
test("agent receives the exact server-owned case id for tool calls", async () => {
let observedMessages: unknown[] = [];
const agent = fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我是生时校正助手。" }),
chunk("finish"),
]);
const { options } = runOptions({
action: "opening",
message: null,
buildAgent: async () => ({
...agent,
stream: async (messages: unknown[]) => {
observedMessages = messages;
return agent.stream();
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(JSON.stringify(observedMessages), new RegExp(CASE_ID));
});
test("first turn with no real skill evidence retries once then fails without saving success", async () => {
const { options, emitted, billing } = runOptions({
accounting: fakeAccounting({