fix(rectification): 分类器失败、引擎忙、整轮超时不再归因错
Independent Staging Quality Gate / validate (push) Canceled after 4m2s
Independent Staging Quality Gate / publish (push) Canceled after 0s

点选题把分类器两次异常说成用户没说清(BUG-722);引擎 429 被压成坏了且重算静默失败(BUG-723);两次 attempt 总预算大于路由 maxDuration(BUG-724)。本单只改归因:classifier_unavailable 请用户重发、busy 分档并可见「这次没有重新比较」、整轮 225s 预算不够则 host fallback。不改计费、分类模型、并发闸门。
This commit is contained in:
jesse-ux
2026-09-16 07:06:57 +08:00
parent 11893c7f97
commit 8b982baf64
23 changed files with 952 additions and 104 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-16 — 校正时系统没接上会直说,记下经历后没重算也会说明
生时校正里,如果这边没判断出你在答哪一问,会请你把刚才那句再发一次,而不是说你没说清。记下经历后如果这次没能重新比较候选,会告诉你经历已经记下、稍后再比,不会假装范围已经在收。Skill 版本不变。
## 2026-09-15 — 外部占星服务额度用尽时马上说明,不再空等
解读若要用外部占星服务、而免费额度已经用完,会马上说明这次没拿到,不再空等几分钟。Skill 版本不变。
+49
View File
@@ -11205,3 +11205,52 @@
- 相关记录:ERR-108、BUG-065、BUG-161、BUG-718
- 复发自:无
- 修复版本:待发布
## BUG-722 | 意图分类器两次异常被说成用户没说清,经历被丢掉
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`classifyTurnIntentWithRetry``POST /api/rectification/agent` 点选题与 `ask_candidate_discriminator` 分支
- 用户现象:点选题下回一句带年月的经历,助手说「我不太确定这句是不是在回答上面的问题」。焦点仍在,但这句话没有当成经历记下,用户只能自己再说一遍。
- 触发条件:`action=message` 且当前焦点是点选题;意图分类器两次尝试都抛异常(超时、供应商 5xx、网络抖动),或两次都解析不出结构化结果。
- 根因:`classified === null`(模型没答上来)与 `classified.intent === "unclear"`(用户确实说不清)被合并进同一条分支,回 `unclearFocusReply` 并落库。采集题分支在 BUG-643 已把 null 记成 `collectIntent=unclassified` 并 fail-open;点选题没有跟上。
- 修复:`classifyTurnIntentWithRetry` 增加 `outcome: classified | unclear | classifier_unavailable`。点选题与 discriminator 分支:`unclear` 维持原回复;`classifier_unavailable` 回「这边没接上,把刚才那句再发一次就行。」,不写证据、不推进焦点、不扣点。服务端打 `rectification_classifier_unavailable` 日志(只含耗时与次数,不含原文或案例 ID)。采集题 `collectIntent=unclassified` 语义不变。不得用关键词/正则兜底。
- 验证:`frontend/tests/rectification-turn-intent-classifier.test.ts`:两次抛异常 → `outcome=classifier_unavailable`;模型返回 `intent: unclear``outcome=unclear`;两条路由文案不同。源码合同禁止 `!classified || classified.intent === "unclear"`
- 防复发:路由不得把分类器 null 与用户 `unclear` 合并。分类器失败不得回退到关键词、正则或词表。
- 相关记录:BUG-643、BUG-635、BUG-522
- 复发自:无(BUG-522 记录末尾写明「意图分类器超时仍是既有缺口,本单不修」,本单关闭该缺口)
- 修复版本:待发布
## BUG-723 | 校正引擎 429 被当成引擎坏了,重算静默失败、范围不动
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`engine-client.ts` `readEngineJson``runV9CandidateScore` / `runV9BlockScan``autoRescoreAfterEvidenceChange`、证据轮主持人正文
- 用户现象:记下经历后助手写「记下了:…」,范围一动不动。没有「这次没有重新比较」之类的说明。只在两个人同时校正、重计算闸门饱和时出现。
- 触发条件:`record-evidence-batch` 已 accepted 后自动重算;Python 返回 429 + `Retry-After` + `ERR_COMPUTE_BUSY`
- 根因:所有非 2xx 压成 `engine_request_failed``autoRescoreAfterEvidenceChange` 整段 `try/catch` 返回 `status=failed` 且不打日志;系统提示词要求工具静默,模型继续写「记下了」。
- 修复:引擎调用按 `busy / http_error / timeout / bad_payload` 分档,读 `Retry-After`,每种非 ok 打 `console.warn`(路径、状态码或错误名、耗时;不含出生资料)。score / block_scan 对 `busy``Retry-After` 退避,上限 `RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT=2`、总退避 `RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS=6_000`。仍失败时 projection 带回 `error_kind`,主持人正文写「这次没有重新比较」,并去掉「收窄」类进度句。不改并发闸门。
- 验证:`frontend/tests/rectification-v9-engine-contract.test.ts` 桩 429 / 500 / 超时 / 坏 JSON;只有 429 触发退避;退避成功后 score 与从未失败路径相同。`rectification-v9-stream.test.ts` 重算失败正文含「这次没有重新比较」、不含「收窄」。
- 防复发:任何调用 Python 引擎的客户端都不得把非 2xx 压平成单一错误码;429 必须单独成档。引擎调用不得用 `catch {}` 吞掉原因,失败必须留服务端日志且用户文案按原因分档。
- 相关记录:BUG-715
- 复发自:BUG-715(那一单的范围写死在星盘页三个文件,防复发没有扫到 `engine-client.ts`
- 修复版本:待发布
## BUG-724 | 两次模型尝试总预算大于路由 maxDuration,重试时无错误码断流
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`runV9AgentTurn``RECTIFICATION_RUN_BUDGET_MS`、agent / regenerate 路由 `maxDuration`
- 用户现象:发生一次可重试失败(`empty_stream``evidence_not_written`)后,用户等三五分钟拿到一个连错误码都没有的断流。
- 触发条件:单次 attempt 210s × 2 = 420s,路由 `maxDuration` 240s。
- 根因:BUG-388 只约束「单次 attempt < maxDuration」,没约束两次尝试总预算。`RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000` 还在 `agent-run.ts``rectification-activity-labels.ts` 各写一遍。
- 修复:引入整轮 `RECTIFICATION_RUN_BUDGET_MS=225_000`(小于 240s)。单次超时取 `min(210s, 剩余预算)`。重试还要 `剩余预算 ≥ RECTIFICATION_MIN_RETRY_ATTEMPT_MS`20s),否则走 host fallback 给出可见正文,不启动注定被砍的第二次尝试。210_000 只在 `rectification-run-budget.ts` 定义一处。不把 attempt 砍到 110s,不把 `maxDuration` 提到 430s。已盖戳 `open_question` 的超时轮保持现状 fail-closed(见既有 stream 测试),不把题干贴进超时回复。
- 验证:`RECTIFICATION_RUN_BUDGET_MS < maxDuration``maxDuration` 从 agent 与 regenerate 路由源码读取。第一次尝试耗尽预算后返回 retryable,不得发起第二次尝试,必须 host fallback。源码合同:`src/` 里只有一处 `210_000`
- 防复发:两次模型尝试的总预算必须由测试断言钉死为小于路由 `maxDuration`,不能只约束单次 attempt。attempt 超时常量只能有一处定义。
- 相关记录:BUG-059、BUG-388
- 复发自:BUG-059(总预算约束);BUG-388 的防复发只写了单次尝试,因此没拦住
- 修复版本:待发布
@@ -0,0 +1,65 @@
# PROGRESS · 三处把系统故障说成别的东西(2026-09-15 / 2026-09-16
工作树:`.worktrees/rectification-failure-attribution-20260915`
分支:`codex/rectification-failure-attribution-20260915`
任务书基线:`6b3248bf`(已被后续合入);代码基线 `origin/staging` @ `a8d29d1b`
本机 Windows。
Skill **未 bump**。未改 `frontend/src/app/page.tsx`、计费口径、分类所用模型、并发闸门、`docs/tasks/README.md` 状态列。未 push `HEAD:staging`
## 开工基线
| 项 | 数字 |
| --- | --- |
| `frontend/src/app/page.tsx` | **1951** 行(未改) |
| 最大 BUG 号 | **BUG-720**(本单占用 **722 / 723 / 724**721 留给 engine-memoization |
| `tsc --noEmit` | 收尾 0 错 |
| `npm run lint` | 0 error(全仓既有 warning 119;本单未新增 error |
`frontend/node_modules` 已存在,未新建 junction。
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 6.1 分类器 unavailable vs unclear | 完成 | `outcome` 三档;点选题与 discriminator 不再合并 null / unclear;采集题 `unclassified` 未改 |
| 6.3 整轮超时预算 | 完成 | `RECTIFICATION_RUN_BUDGET_MS=225s < maxDuration 240s`;不足最小重试时长走 host fallback |
| 6.2 引擎分档 + 可见「这次没有重算」 | 完成 | `busy/http_error/timeout/bad_payload`;429 有限退避;失败正文含「这次没有重新比较」 |
| 6.4 Bug 历史 | 完成 | BUG-722 / 723 / 724 |
## 实现要点
- `classifyTurnIntentWithRetry` 返回 `outcome``classifier_unavailable` 专指两次都没拿到结构化分类(抛异常或解析空)。点选题回「这边没接上,把刚才那句再发一次就行。」焦点保持 active,不写证据、不扣点。
- `readEngineJson` 对齐星盘页的四档失败。score / block_scan 对 429 按 `Retry-After` 退避,上限 2 次、总退避 6s(具名常量)。`autoRescoreAfterEvidenceChange` 失败打 `rectification_rescore_failed`projection 带 `error_kind`。主持人用 `withRescoreSkippedNotice` 补「这次没有重新比较」,并剥掉「收窄 / 范围从」进度句。
- 整轮 deadline 225s。第二次尝试只在剩余 ≥ 20s 时启动。`210_000` 只定义在 `rectification-run-budget.ts``activity-labels``agent-run` 从那里 import。
- `agent-run` 不再顶层 import `@/mastra/agentic-rectification`(该模块会在加载时创建 skill symlink)。步进预算抽到 `step-budget.ts`,避免 Windows 无 symlink 权限时 stream 测试整文件无法加载。`agentic-rectification.ts` 再导出,既有 import 路径不变。
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `rectification-answer-choice.test.ts` 超时合同 | `agent-run``= 210_000`,只断言 210s < 240s | `210_000` 只在 `rectification-run-budget.ts`;整轮 225s < 两条路由读出的 `maxDuration` | BUG-724 总预算必须小于路由墙 |
| `rectification-window-cluster-cap-20260909.test.ts` 分类调用 | `await classifyRectificationTurnIntent` | `await classifyTurnIntentWithRetry` | BUG-722 discriminator 与点选题同一入口 |
| `rectification-collect-stall.test.ts` 点选题切片 | `classified.intent ===` | `classified?.intent ===` | BUG-722 classified 在 unavailable 时为 null |
| `rectification-v9-status-security.test.ts` stale policy 重算失败形状 | 原始 Error 文案 `stale policy must rescore` | `RectificationEngineError` / `http_error`,且确实打了 `/score` | BUG-723 抛错不得把上游原文泄漏给工具层 |
未弱化其它既有断言。`timeout after a stamped open_question fails closed` 保持现状(不把题干贴进超时回复)。
## 测试
| 命令 | 结果 |
| --- | --- |
| `tsc --noEmit` | **0 错** |
| `npm run lint` | **0 error / 119 warning**(与既有全仓 warning 同量级) |
| 任务书所列 tsx 测试(除 `rectification-v9-agent.test.ts` | 定向套件绿:classifier / stream50 / answer-choice / spoken-collect / unwritten-evidence / chart-view-engine / engine-contract / voice-copy / window-cluster / eight-method |
| `rectification-v9-agent.test.ts` | **环境缺口**:模块加载 `agentic-rectification``symlinkSync(..., "dir")` EPERM。本机 Node 只能建 junction。该失败在改本单之前就存在,不是本单回归。 |
| `page.tsx` | 仍 1951 行,未改 |
| 全量 `npm test` | `# tests 3189 / # pass 3104 / # fail 71 / # skipped 14`(修 collect-stall / status-security 两处源码合同后,这两条已绿)。失败集中在无 Docker 的 database-*、部署/Caddy/shell、Windows symlink EPERM`rectification-v9-agent.test.ts``api-service-unavailable` 动态 import 路由、skill-binding)。不含本单定向套件。 |
| `npx next build --webpack` | compile + TypeScript 过;Collecting page data 死在既有 `SkillPackageRegistryError` EPERM symlink`/api/daily-starlanguage`),与本单无关。Turbopack 因 `frontend/node_modules` junction 指向主仓而直接 panic。未能本机核对 `/` 是否 `○ Static`。 |
## 环境缺口
- 无 Docker:数据库套件红,不伪装成通过。
- 无登录态、无 Chrome:浏览器级验收不在本单。
- Windows 无 symlink 权限:`symlinkSync(..., "dir")` EPERM。凡顶层加载 `getRectificationV9Agent` / `RECTIFICATION_V9_SKILL_PATH` 的测试或 `next build` 收集路由都会红。本机 Node 只能建 junction。stream / unwritten-evidence 因 runner 不再顶层加载该模块而可跑。
- `next build`webpack 编译过、tsc 过;收集页面数据时 skill runtime symlink EPERM。不伪装成 `/` ○ Static 已核过。
@@ -35,7 +35,6 @@ import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectif
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { CHOICE_STOP_LABEL, parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
import {
classifyRectificationTurnIntent,
optionIdForAnswerClass,
collectFocusCloseStatus,
shouldContinueAgentForDatedEvent,
@@ -459,7 +458,16 @@ export async function POST(request: Request) {
const classified = intent.classified;
expectedWrite = intent.expectedWrite;
writeClassified = true;
if (!classified || classified.intent === "unclear") {
if (intent.outcome === "classifier_unavailable") {
const narration = RECTIFICATION_USER_COPY.classifierUnavailableReply;
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (intent.outcome === "unclear") {
const narration = RECTIFICATION_USER_COPY.unclearFocusReply;
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
@@ -468,7 +476,7 @@ export async function POST(request: Request) {
});
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (classified.intent === "answer_current_focus") {
if (classified?.intent === "answer_current_focus") {
if (!classified.answer_class) {
return completedMessageResponse(RECTIFICATION_USER_COPY.questionUpdated, requestId, caseId);
}
@@ -499,7 +507,7 @@ export async function POST(request: Request) {
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
}
if (classified.intent === "stop_rectification" || classified.intent === "ask_about_result") {
if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") {
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const applied = await applyRectificationChoice(accounting, {
userId,
@@ -631,16 +639,21 @@ export async function POST(request: Request) {
});
const open = openQuestionFromPersistedFocus(persisted);
if (open && persisted.focus && isRenderableChoiceOpenQuestion(open)) {
let classified = null;
try {
classified = await classifyRectificationTurnIntent(selectedModel, {
focus: persisted.focus,
userMessage: parsed.data.message ?? "",
caseStatus,
signal: request.signal,
const intent = await classifyTurnIntentWithRetry(resolvedModel, {
focus: persisted.focus,
userMessage: parsed.data.message ?? "",
caseStatus,
signal: request.signal,
});
const classified = intent.classified;
if (intent.outcome === "classifier_unavailable") {
const narration = RECTIFICATION_USER_COPY.classifierUnavailableReply;
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
} catch {
classified = null;
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
}
if (classified?.intent === "answer_current_focus" && classified.answer_class) {
const optionId = optionIdForAnswerClass(persisted.focus, classified.answer_class);
@@ -6,6 +6,9 @@ import type {
PublicRectificationActivity,
PublicRectificationTool,
} from "./rectification-agentic/v9/public-receipt.ts";
import { RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS as ATTEMPT_TIMEOUT_MS } from "./rectification-run-budget.ts";
export { RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS } from "./rectification-run-budget.ts";
export const RECTIFICATION_TOOL_DONE_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
@@ -37,7 +40,6 @@ export const RECTIFICATION_ACTIVITY_PROGRESS_LABELS: Readonly<Record<PublicRecti
export const RECTIFICATION_ANALYZING_LIVE_LABEL = "正在分析…";
export const RECTIFICATION_SLOW_STEP_MS = 45_000;
export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000;
export const RECTIFICATION_TIMEOUT_WARN_BEFORE_MS = 20_000;
export function rectificationSlowProgressLabel(tool: PublicRectificationTool | string | null | undefined): string {
@@ -57,7 +59,7 @@ export function rectificationLiveProgressLabel(input: {
now?: number;
}): string {
const now = input.now ?? Date.now();
if (now - input.runStartedAt >= RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS) {
if (now - input.runStartedAt >= ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS) {
return "即将超时,会自动重试或提示";
}
if (now - input.stepStartedAt >= RECTIFICATION_SLOW_STEP_MS) {
@@ -170,6 +170,7 @@ export const RECTIFICATION_USER_COPY = {
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
classifierUnavailableReply: "这边没接上,把刚才那句再发一次就行。",
declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
@@ -189,6 +190,7 @@ export const RECTIFICATION_USER_COPY = {
noCandidatesGate: "当前还排不出可比较的候选时间。",
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
rescoreSkipped: "这次没有重新比较,稍后再比一次。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
rangeDeliveryTitle: "目前范围",
@@ -326,6 +328,26 @@ export function withCompareFailedRetryNotice(body: string): string {
return `${spoken}\n\n${notice}`;
}
export function stripRangeProgressClaims(body: string): string {
const spoken = body.trim();
if (!spoken) return "";
const kept = spoken
.split(/(?<=[。!?])/)
.map((part) => part.trim())
.filter((part) => part && !/收窄|范围从/.test(part));
return kept.join("");
}
export function withRescoreSkippedNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.rescoreSkipped;
const spoken = stripRangeProgressClaims(body);
if (spoken.includes("这次没有重新比较")) return spoken;
if (!spoken) return `记下了。${notice}`;
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}`;
if (spoken.includes("记下了")) return `${prefix}${notice}`;
return `${prefix}记下了。${notice}`;
}
export function withLastSuccessfulCompareNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.lastSuccessfulCompareRange;
const spoken = body.trim();
@@ -554,6 +576,7 @@ export function listUserVisibleCopy(): string[] {
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY.choicePrompt,
RECTIFICATION_USER_COPY.unclearFocusReply,
RECTIFICATION_USER_COPY.classifierUnavailableReply,
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
RECTIFICATION_USER_COPY.questionUpdated,
RECTIFICATION_USER_COPY.adoptCue,
@@ -575,6 +598,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
RECTIFICATION_USER_COPY.compareFailedRetry,
RECTIFICATION_USER_COPY.rescoreSkipped,
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
RECTIFICATION_USER_COPY.deferredCareerWindow,
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
@@ -11,7 +11,7 @@
* successful attempt.
*/
import type { Agent } from "@mastra/core/agent";
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
import { type RectificationAgentAction, resolveRectificationStepBudget } from "./step-budget";
import {
createV10RunAttempt,
finalizeV10RunAttempt,
@@ -43,7 +43,16 @@ import {
openingSpokenBody,
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
withRescoreSkippedNotice,
} from "../user-copy";
import {
RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S,
RECTIFICATION_MIN_RETRY_ATTEMPT_MS,
RECTIFICATION_RUN_BUDGET_MS,
attemptTimeoutForRemainingBudget,
canStartRetryAttempt,
} from "../../rectification-run-budget.ts";
import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview, composeIdleGapIntoSpoken } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
@@ -62,6 +71,7 @@ import {
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
import {
batchResultFromToolChunk,
batchRescoreFailed,
composeHostFallbackNarration,
lastCompletedPublicTool,
publicWriteToolCompleted,
@@ -116,12 +126,18 @@ export type V9AgentRunOptions = Readonly<{
timeContext?: string;
generationModel?: unknown;
attemptTimeoutMs?: number;
runBudgetMs?: number;
minRetryAttemptMs?: number;
expectedWrite?: "evidence" | "none" | "unknown";
collectIntent?: "classified" | "unclassified" | null;
}>;
export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000;
export const RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240;
export {
RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S,
RECTIFICATION_MIN_RETRY_ATTEMPT_MS,
RECTIFICATION_RUN_BUDGET_MS,
};
export type V9AgentRunResult = Readonly<{
ok: boolean;
@@ -445,11 +461,17 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
const startedAt = Date.now();
const runBudgetMs = options.runBudgetMs ?? RECTIFICATION_RUN_BUDGET_MS;
const minRetryAttemptMs = options.minRetryAttemptMs ?? RECTIFICATION_MIN_RETRY_ATTEMPT_MS;
const attemptCapMs = options.attemptTimeoutMs ?? RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS;
const runDeadlineAt = startedAt + runBudgetMs;
await emit({ type: "run.started" });
let lastAttemptError: string | null = null;
let finalOutcome: AttemptOutcome | null = null;
for (let attemptNumber = 1; attemptNumber <= MAX_ATTEMPTS; attemptNumber += 1) {
const remainingMs = runDeadlineAt - Date.now();
if (attemptNumber > 1 && !canStartRetryAttempt(remainingMs, minRetryAttemptMs)) break;
const claim = await createV10RunAttempt(
accounting,
userId,
@@ -716,7 +738,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const timeout = setTimeout(() => {
timedOut = true;
abortController.abort();
}, options.attemptTimeoutMs ?? RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS);
}, attemptTimeoutForRemainingBudget(runDeadlineAt - Date.now(), attemptCapMs));
let skillBound = true;
let caseLoaded = false;
@@ -994,7 +1016,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
}
if (toolTerminalStatus.get("rectification-compare-candidates") === "failed") {
if (toolTerminalStatus.get("rectification-compare-candidates") === "failed" && !batchRescoreFailed(batchToolResult)) {
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
}
@@ -1064,10 +1086,38 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
return failedAttempt(attemptId, mapped);
}
} else if (!answerText.trim() && !await applyHostFallback()) {
const retryable = !publicWriteToolCompleted(toolTerminalStatus);
const couldRetry = !publicWriteToolCompleted(toolTerminalStatus) && attemptNumber < MAX_ATTEMPTS;
const hasRetryBudget = canStartRetryAttempt(runDeadlineAt - Date.now(), minRetryAttemptMs);
if (couldRetry && hasRetryBudget) {
return {
ok: false,
status: "retryable",
errorCode: "empty_stream",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [...phases],
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
if (couldRetry && !hasRetryBudget) {
hostFallbackUsed = true;
await recordPhase("answer.host_fallback");
await publish({ type: "answer.host_fallback" });
await emitVisibleSpoken(
turnExpectsEvidenceWrite(action, options.expectedWrite)
? RECTIFICATION_USER_COPY.evidenceNotRecorded
: RECTIFICATION_USER_COPY.hostNarrationFallback,
);
return completeAttempt(false);
}
return {
ok: false,
status: retryable ? "retryable" : "failed",
status: "failed",
errorCode: "empty_stream",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
@@ -1084,7 +1134,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|| (action !== "opening" && action !== "read_only" && answerClaimsEvidenceRecorded(answerText));
if (needsWrite && !publicWriteToolCompleted(toolTerminalStatus) && !hostFallbackUsed) {
await retractSpoken();
if (attemptNumber < MAX_ATTEMPTS) {
if (
attemptNumber < MAX_ATTEMPTS
&& canStartRetryAttempt(runDeadlineAt - Date.now(), minRetryAttemptMs)
) {
return {
ok: false,
status: "retryable",
@@ -1141,11 +1194,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const rangeAfterEvidence = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterEvidence,
);
if (batchRescoreFailed(batchToolResult)) {
answerText = withRescoreSkippedNotice(answerText);
} else {
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterEvidence,
);
}
answerText = stripVerbalWindowChange(answerText)
|| RECTIFICATION_USER_COPY.declaredWindowLockedReply;
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
@@ -26,16 +26,114 @@ import { questionContractVersionIsCompatible } from "./probe-question-contract";
import { resolveAyanamsa } from "../../ayanamsa.ts";
import { parseBlockScanPayload, type BlockScanBlock } from "./block-scan.ts";
export type EngineCallFailureKind = "busy" | "http_error" | "timeout" | "bad_payload";
export const RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT = 2;
export const RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS = 6_000;
export class RectificationEngineError extends Error {
readonly code: string;
readonly kind?: EngineCallFailureKind;
readonly httpStatus?: number;
readonly retryAfterSeconds?: number;
readonly elapsedMs?: number;
readonly path?: string;
readonly errorName?: string;
constructor(code: string, message: string) {
constructor(
code: string,
message: string,
extras?: Readonly<{
kind?: EngineCallFailureKind;
httpStatus?: number;
retryAfterSeconds?: number;
elapsedMs?: number;
path?: string;
errorName?: string;
}>,
) {
super(message);
this.name = "RectificationEngineError";
this.code = code;
this.kind = extras?.kind;
this.httpStatus = extras?.httpStatus;
this.retryAfterSeconds = extras?.retryAfterSeconds;
this.elapsedMs = extras?.elapsedMs;
this.path = extras?.path;
this.errorName = extras?.errorName;
}
}
export function engineFailureKind(error: unknown): EngineCallFailureKind | null {
if (error instanceof RectificationEngineError && error.kind) return error.kind;
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
return "timeout";
}
if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
return "timeout";
}
return null;
}
export function parseEngineRetryAfterSeconds(value: string | null | undefined): number | null {
if (!value) return null;
const trimmed = value.trim();
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
const seconds = Number(trimmed);
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
}
const at = Date.parse(trimmed);
if (!Number.isFinite(at)) return null;
const seconds = (at - Date.now()) / 1000;
return seconds > 0 ? seconds : 0;
}
export function nextEngineBusyBackoffMs(input: {
retryAfterSeconds?: number | null;
retriesUsed: number;
backoffUsedMs: number;
}): number | null {
if (input.retriesUsed >= RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT) return null;
const remaining = RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS - input.backoffUsedMs;
if (remaining < 0) return null;
const requested = Math.max(0, Math.round((input.retryAfterSeconds ?? 1) * 1000));
if (requested === 0) return 0;
if (remaining <= 0) return null;
return Math.min(requested, remaining);
}
const defaultEngineBusyWait = (ms: number) => new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
let engineBusyWait = defaultEngineBusyWait;
export function setEngineBusyWaitForTests(wait: ((ms: number) => Promise<void>) | null): void {
engineBusyWait = wait ?? defaultEngineBusyWait;
}
function logEngineCallFailure(input: {
path: string;
kind: EngineCallFailureKind;
elapsedMs: number;
httpStatus?: number;
errorName?: string;
}): void {
console.warn("rectification_engine_failed", {
path: input.path,
status: input.kind,
httpStatus: input.httpStatus ?? null,
errorName: input.errorName ?? null,
elapsedMs: input.elapsedMs,
});
}
function persistenceCodeForKind(kind: EngineCallFailureKind): string {
if (kind === "timeout") return "timeout";
if (kind === "bad_payload") return "engine_invalid_response";
return "engine_request_failed";
}
export type V9EngineEvent = Readonly<{
id: string;
domain: string;
@@ -186,30 +284,101 @@ function engineBase(): string {
return process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200";
}
async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise<Record<string, unknown>> {
const response = await fetch(`${engineBase()}${path}`, {
...init,
signal: AbortSignal.timeout(timeoutMs),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data && typeof data === "object"
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? `Jyotish API ${response.status}`)
: `Jyotish API ${response.status}`;
throw new RectificationEngineError("engine_request_failed", message);
}
if (!data || typeof data !== "object") {
throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response");
}
return data as Record<string, unknown>;
function retryAfterFromHeaders(headers: { get?(name: string): string | null } | undefined): number | null {
if (!headers || typeof headers.get !== "function") return null;
return parseEngineRetryAfterSeconds(headers.get("retry-after") ?? headers.get("Retry-After"));
}
async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise<Record<string, unknown>> {
return readEngineJson(path, {
async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise<Record<string, unknown>> {
const started = Date.now();
try {
const response = await fetch(`${engineBase()}${path}`, {
...init,
signal: AbortSignal.timeout(timeoutMs),
});
const elapsedMs = Date.now() - started;
const data = await response.json().catch(() => null);
if (!response.ok) {
const kind: EngineCallFailureKind = response.status === 429 ? "busy" : "http_error";
logEngineCallFailure({
path,
kind,
elapsedMs,
httpStatus: response.status,
});
const message = data && typeof data === "object"
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? `Jyotish API ${response.status}`)
: `Jyotish API ${response.status}`;
throw new RectificationEngineError(persistenceCodeForKind(kind), message, {
kind,
httpStatus: response.status,
retryAfterSeconds: retryAfterFromHeaders(response.headers) ?? undefined,
elapsedMs,
path,
});
}
if (!data || typeof data !== "object") {
logEngineCallFailure({
path,
kind: "bad_payload",
elapsedMs,
httpStatus: response.status,
});
throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response", {
kind: "bad_payload",
httpStatus: response.status,
elapsedMs,
path,
});
}
return data as Record<string, unknown>;
} catch (error) {
if (error instanceof RectificationEngineError) throw error;
const elapsedMs = Date.now() - started;
const errorName = error instanceof Error ? error.name : "Error";
const kind: EngineCallFailureKind = errorName === "TimeoutError" || errorName === "AbortError"
? "timeout"
: "http_error";
logEngineCallFailure({ path, kind, elapsedMs, errorName });
throw new RectificationEngineError(persistenceCodeForKind(kind), "Jyotish API request failed", {
kind,
elapsedMs,
path,
errorName,
});
}
}
async function postEngine(
path: string,
body: unknown,
timeoutMs = 60_000,
options?: Readonly<{ busyRetry?: boolean }>,
): Promise<Record<string, unknown>> {
const init: RequestInit = {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}, timeoutMs);
};
if (!options?.busyRetry) return readEngineJson(path, init, timeoutMs);
let retriesUsed = 0;
let backoffUsedMs = 0;
while (true) {
try {
return await readEngineJson(path, init, timeoutMs);
} catch (error) {
if (!(error instanceof RectificationEngineError) || error.kind !== "busy") throw error;
const waitMs = nextEngineBusyBackoffMs({
retryAfterSeconds: error.retryAfterSeconds,
retriesUsed,
backoffUsedMs,
});
if (waitMs == null) throw error;
if (waitMs > 0) await engineBusyWait(waitMs);
retriesUsed += 1;
backoffUsedMs += waitMs;
}
}
}
async function getEngine(path: string, timeoutMs = 5_000): Promise<Record<string, unknown>> {
@@ -645,13 +814,16 @@ export async function runV9VedastroValidate(input: {
raw: data,
};
} catch (error) {
const code = error instanceof RectificationEngineError
? error.code === "engine_invalid_response"
const kind = engineFailureKind(error);
const code = kind === "timeout" || (error instanceof RectificationEngineError && error.code === "timeout")
? "timeout"
: error instanceof RectificationEngineError && (error.kind === "bad_payload" || error.code === "engine_invalid_response")
? "engine_invalid_response"
: "engine_request_failed"
: error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")
? "timeout"
: "unknown";
: error instanceof RectificationEngineError
? "engine_request_failed"
: error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")
? "timeout"
: "unknown";
return {
status: "failed",
canConfirmExactMinute: false,
@@ -671,7 +843,7 @@ export async function runV9CandidateScore(input: {
columnTimes?: readonly string[];
refreshProbes?: boolean;
}): Promise<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input), 60_000, { busyRetry: true });
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger);
@@ -826,7 +998,7 @@ export async function runV9BlockScan(input: {
...engineRequestBody(input),
minute_step: input.minuteStep ?? 10,
...(input.blocks?.length ? { blocks: input.blocks } : {}),
});
}, 60_000, { busyRetry: true });
const parsed = parseBlockScanPayload({
blocks: data.blocks,
algorithm_version: data.algorithm_version,
@@ -115,6 +115,12 @@ export function batchResultFromToolChunk(chunk: {
return result;
}
export function batchRescoreFailed(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const rescore = (batchResult as { rescore?: { status?: unknown } }).rescore;
return rescore?.status === "failed";
}
export function composeHostFallbackNarration(batchResult: unknown): string | null {
if (batchResult == null) return null;
if (isToolInputRejection(batchResult)) return null;
@@ -0,0 +1,28 @@
/**
* Bounded step budgets for a rectification Agent turn.
* Kept off the Mastra agent module so the runner can load without creating
* a runtime skill symlink.
*/
export type RectificationAgentAction =
| "opening"
| "read_only"
| "evidence"
| "rescore"
| "accept"
| "confirm";
export const RECTIFICATION_AGENT_STEP_BUDGETS: Readonly<Record<RectificationAgentAction, number>> = {
opening: 6,
read_only: 6,
evidence: 8,
rescore: 12,
accept: 6,
confirm: 6,
};
export const RECTIFICATION_AGENT_MAX_STEPS = 12;
export const RECTIFICATION_AGENT_HARD_STEP_LIMIT = 16;
export function resolveRectificationStepBudget(action: RectificationAgentAction): number {
return Math.min(RECTIFICATION_AGENT_HARD_STEP_LIMIT, RECTIFICATION_AGENT_STEP_BUDGETS[action]);
}
@@ -2057,6 +2057,7 @@ export function safeToolErrorCode(error: unknown): string {
"engine_invalid_response",
"engine_profile_incomplete",
"no_scorable_evidence",
"timeout",
];
if (error instanceof Error && error.name === "RectificationEngineError") {
const code = "code" in error && typeof error.code === "string" ? error.code : "";
@@ -53,6 +53,14 @@ export function shouldContinueAgentForDatedEvent(
export type ExpectedWriteSignal = "evidence" | "none" | "unknown";
export type TurnIntentClassifierOutcome = "classified" | "unclear" | "classifier_unavailable";
export type TurnIntentClassifierResult = Readonly<{
classified: RectificationTurnIntent | null;
expectedWrite: ExpectedWriteSignal;
outcome: TurnIntentClassifierOutcome;
}>;
export function expectedWriteFromCollectIntent(
classified: RectificationTurnIntent | null,
focus?: ConversationFocus | null,
@@ -72,6 +80,15 @@ export function expectedWriteFromCollectIntent(
return "none";
}
export function turnIntentOutcome(
classified: RectificationTurnIntent | null,
): TurnIntentClassifierOutcome {
if (!classified) return "classifier_unavailable";
return classified.intent === "unclear" ? "unclear" : "classified";
}
type ClassifyTurnIntent = typeof classifyRectificationTurnIntent;
export async function classifyTurnIntentWithRetry(
model: ResolvedLanguageModel,
input: {
@@ -80,21 +97,28 @@ export async function classifyTurnIntentWithRetry(
caseStatus: string;
signal?: AbortSignal;
},
): Promise<{ classified: RectificationTurnIntent | null; expectedWrite: ExpectedWriteSignal }> {
classify: ClassifyTurnIntent = classifyRectificationTurnIntent,
): Promise<TurnIntentClassifierResult> {
const started = Date.now();
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const classified = await classifyRectificationTurnIntent(model, input);
const classified = await classify(model, input);
if (classified) {
return {
classified,
expectedWrite: expectedWriteFromCollectIntent(classified, input.focus),
outcome: turnIntentOutcome(classified),
};
}
} catch {
// One retry, then fail-open as unknown.
// One retry, then fail-open as unknown / classifier_unavailable.
}
}
return { classified: null, expectedWrite: "unknown" };
console.warn("rectification_classifier_unavailable", {
elapsedMs: Date.now() - started,
attempts: 2,
});
return { classified: null, expectedWrite: "unknown", outcome: "classifier_unavailable" };
}
export function optionIdForAnswerClass(
@@ -0,0 +1,27 @@
/**
* Single definition of rectification Agent wall-clock budgets.
*
* Attempt cap stays at 210s (BUG-388). The whole-run budget is strictly less
* than the route `maxDuration` so a retry cannot start a second attempt that
* the edge will cut (BUG-059 / BUG-724).
*/
export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000;
export const RECTIFICATION_RUN_BUDGET_MS = 225_000;
export const RECTIFICATION_MIN_RETRY_ATTEMPT_MS = 20_000;
export const RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240;
export function attemptTimeoutForRemainingBudget(
remainingMs: number,
attemptCapMs = RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
): number {
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return 0;
if (!Number.isFinite(attemptCapMs) || attemptCapMs <= 0) return 0;
return Math.min(attemptCapMs, remainingMs);
}
export function canStartRetryAttempt(
remainingMs: number,
minAttemptMs = RECTIFICATION_MIN_RETRY_ATTEMPT_MS,
): boolean {
return Number.isFinite(remainingMs) && remainingMs >= minAttemptMs;
}
+7 -31
View File
@@ -21,37 +21,13 @@ export const RECTIFICATION_V9_PACKAGE_PATH = activeRectificationSkill.resolvedPa
/** The basename-correct alias that is safe to pass to Mastra WorkspaceSkills. */
export const RECTIFICATION_V9_SKILL_PATH = resolveSkillPackageRuntimePath(activeRectificationSkill);
/**
* V9 rectification agent actions. The action drives the bounded step budget;
* the server never lets the model run an unbounded loop.
*/
export type RectificationAgentAction =
| "opening"
| "read_only"
| "evidence"
| "rescore"
| "accept"
| "confirm";
/**
* Bounded step budgets per action (plan §6.4). The hard ceiling is enforced
* by min() so a misbehaving action can never exceed the global cap.
*/
export const RECTIFICATION_AGENT_STEP_BUDGETS: Readonly<Record<RectificationAgentAction, number>> = {
opening: 6,
read_only: 6,
evidence: 8,
rescore: 12,
accept: 6,
confirm: 6,
};
export const RECTIFICATION_AGENT_MAX_STEPS = 12;
export const RECTIFICATION_AGENT_HARD_STEP_LIMIT = 16;
export function resolveRectificationStepBudget(action: RectificationAgentAction): number {
return Math.min(RECTIFICATION_AGENT_HARD_STEP_LIMIT, RECTIFICATION_AGENT_STEP_BUDGETS[action]);
}
export {
RECTIFICATION_AGENT_HARD_STEP_LIMIT,
RECTIFICATION_AGENT_MAX_STEPS,
RECTIFICATION_AGENT_STEP_BUDGETS,
resolveRectificationStepBudget,
type RectificationAgentAction,
} from "../lib/rectification-agentic/v9/step-budget.ts";
/**
* System prompt: high-priority behavioral and truth boundaries in Simplified
+18 -4
View File
@@ -163,6 +163,7 @@ import {
executedMethodsFromLedger,
runV9RangeReading,
runV9BlockScan,
engineFailureKind,
type V9EngineScoreResult,
} from "@/lib/rectification-agentic/v9/engine-client";
@@ -1028,11 +1029,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
const dossier = await loadV9CaseDossier(accounting, userId, targetCaseId);
if (isTerminalStatus(dossier.case.status as RectificationCaseStatus)) {
return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null };
}
const parsed = parseDossierForTools(dossier);
if (parsed.scorable.length === 0 || !parsed.case.candidateRange) {
return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null };
}
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
if (parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) {
@@ -1043,6 +1044,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
status: "skipped" as const,
executedMethods: [] as const,
errorCode: null,
errorKind: null,
cached: true,
openQuestion: persisted
? openQuestionFromPersistedFocus(persisted.persistedFocus)
@@ -1068,14 +1070,24 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
status: "completed" as const,
executedMethods: scored.score.executedMethods,
errorCode: null,
errorKind: null,
cached: scored.persisted.cached,
openQuestion: openQuestionFromPersistedFocus(persisted.persistedFocus),
};
} catch (error) {
const errorKind = engineFailureKind(error);
console.warn("rectification_rescore_failed", {
status: errorKind ?? "http_error",
errorCode: safeToolErrorCode(error),
elapsedMs: error instanceof Error && "elapsedMs" in error && typeof error.elapsedMs === "number"
? error.elapsedMs
: null,
});
return {
status: "failed" as const,
executedMethods: [] as const,
errorCode: safeToolErrorCode(error),
errorKind,
cached: false,
openQuestion: null,
};
@@ -1637,7 +1649,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
}
const rescore = result.acceptedCount > 0
? await autoRescoreAfterEvidenceChange(input.caseId)
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null };
const acceptedRecaps = writeItems.flatMap((item, offset) => {
const recorded = result.items[offset];
if (!recorded || recorded.outcome !== "accepted") return [];
@@ -1675,6 +1687,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
status: rescore.status,
executed_methods: rescore.executedMethods,
error_code: rescore.errorCode,
error_kind: rescore.errorKind,
},
open_question: rescore.openQuestion,
};
@@ -1813,7 +1826,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
}
const rescore = result.status === "confirmed"
? await autoRescoreAfterEvidenceChange(input.caseId)
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null };
const projection = {
evidence_id: result.evidenceId,
status: result.status,
@@ -1822,6 +1835,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
status: rescore.status,
executed_methods: rescore.executedMethods,
error_code: rescore.errorCode,
error_kind: rescore.errorKind,
},
open_question: rescore.openQuestion,
};
@@ -16,6 +16,7 @@ import {
nonConvergingRangeNarration,
openingSpokenBody,
stopReasonPrefix,
withRescoreSkippedNotice,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
@@ -229,6 +230,15 @@ test("settled assistant body with a focus has no question-mark sentences", () =>
assert.doesNotMatch(linked[0]?.text ?? "", /[?]/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.evidenceNotRecorded));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.classifierUnavailableReply));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rescoreSkipped));
assert.notEqual(
RECTIFICATION_USER_COPY.classifierUnavailableReply,
RECTIFICATION_USER_COPY.unclearFocusReply,
);
assert.match(RECTIFICATION_USER_COPY.rescoreSkipped, /这次没有重新比较/);
assert.match(withRescoreSkippedNotice("记下了:2016 年 3 月入学。范围在收窄。"), /这次没有重新比较/);
assert.doesNotMatch(withRescoreSkippedNotice("记下了:2016 年 3 月入学。范围在收窄。"), /收窄/);
assert.doesNotMatch(RECTIFICATION_USER_COPY.evidenceNotRecorded, /系统|模型/);
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
@@ -1463,14 +1463,24 @@ test("the public agent route treats structured choice as a non-model command", (
test("rectification attempt timeout stays under the agent route budget", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const budget = readFileSync(new URL("../src/lib/rectification-run-budget.ts", import.meta.url), "utf8");
const regenerate = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url),
"utf8",
);
assert.match(agentRun, /RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/);
assert.match(agentRun, /RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240/);
assert.match(regenerate, /export const maxDuration = 240/);
assert.ok(210_000 < 240_000);
const agentRoute = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
// 原值: agent-run 内 RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000,只断言单次 < 240s
// 新值: 210_000 只定义一次;整轮 RECTIFICATION_RUN_BUDGET_MS 从路由模块读取 maxDuration 并必须更小
// 原因: BUG-724 复发自 BUG-059,两次尝试总预算必须显式小于路由 maxDuration
assert.match(budget, /export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/);
assert.match(budget, /export const RECTIFICATION_RUN_BUDGET_MS = 225_000/);
assert.doesNotMatch(agentRun, /210_000/);
const agentMax = Number(agentRoute.match(/export const maxDuration = (\d+)/)?.[1]);
const regenerateMax = Number(regenerate.match(/export const maxDuration = (\d+)/)?.[1]);
assert.equal(agentMax, 240);
assert.equal(regenerateMax, 240);
assert.ok(225_000 < agentMax * 1000);
assert.ok(225_000 < regenerateMax * 1000);
assert.match(agentRun, /RETRYABLE_ERROR_CODES = new Set\(\[/);
const retryable = agentRun.slice(
agentRun.indexOf("const RETRYABLE_ERROR_CODES"),
@@ -1340,9 +1340,12 @@ test("has_new_dated_event continues into the agent after applying the answer", (
assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/);
assert.match(fastPath, /shouldContinueAgentForDatedEvent/);
assert.match(fastPath, /deferFollowup:\s*continueToAgent/);
// 原值: if (classified.intent === "answer_current_focus")
// 新值: if (classified?.intent === "answer_current_focus")
// 原因: BUG-722 分类器 unavailable 时 classified 可能为 null,不得再当用户没说清
const choiceApply = fastPath.slice(
fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"),
fastPath.indexOf("if (classified.intent === \"stop_rectification\")"),
fastPath.indexOf("if (classified?.intent === \"answer_current_focus\")"),
fastPath.indexOf("if (classified?.intent === \"stop_rectification\""),
);
assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)"));
assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/);
@@ -9,7 +9,11 @@ import {
shouldDeclineCollectFocus,
collectFocusCloseStatus,
expectedWriteFromCollectIntent,
classifyTurnIntentWithRetry,
turnIntentOutcome,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts";
@@ -227,5 +231,72 @@ test("classifyTurnIntentWithRetry fails open as unknown after two misses", () =>
assert.match(src, /export async function classifyTurnIntentWithRetry/);
assert.match(src, /for \(let attempt = 0; attempt < 2/);
assert.match(src, /expectedWrite: "unknown"/);
assert.match(src, /outcome: "classifier_unavailable"/);
assert.doesNotMatch(src, /\(\?:19\|20\)\\d\{2\}/);
});
const dummyModel = { id: "test-model" } as ResolvedLanguageModel;
test("two classifier throws are classifier_unavailable, not unclear", async () => {
let calls = 0;
const warnings: unknown[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args);
};
try {
const result = await classifyTurnIntentWithRetry(
dummyModel,
{ userMessage: "2016年3月入学", caseStatus: "collecting_evidence" },
async () => {
calls += 1;
throw Object.assign(new Error("upstream 5xx"), { name: "APICallError" });
},
);
assert.equal(calls, 2);
assert.equal(result.classified, null);
assert.equal(result.expectedWrite, "unknown");
assert.equal(result.outcome, "classifier_unavailable");
assert.equal(turnIntentOutcome(null), "classifier_unavailable");
const line = JSON.stringify(warnings[0]);
assert.match(line, /rectification_classifier_unavailable/);
assert.match(line, /elapsedMs/);
assert.doesNotMatch(line, /2016|入学|userMessage/);
} finally {
console.warn = original;
}
});
test("a model unclear intent is outcome unclear and uses a different reply than unavailable", async () => {
const result = await classifyTurnIntentWithRetry(
dummyModel,
{ userMessage: "随便吧", caseStatus: "collecting_evidence" },
async () => ({ intent: "unclear", answer_class: null }),
);
assert.equal(result.outcome, "unclear");
assert.equal(result.classified?.intent, "unclear");
assert.equal(result.expectedWrite, "none");
assert.equal(turnIntentOutcome({ intent: "unclear", answer_class: null }), "unclear");
assert.notEqual(
RECTIFICATION_USER_COPY.classifierUnavailableReply,
RECTIFICATION_USER_COPY.unclearFocusReply,
);
assert.match(RECTIFICATION_USER_COPY.classifierUnavailableReply, /再发一次/);
assert.match(RECTIFICATION_USER_COPY.unclearFocusReply, /不太确定/);
});
test("route no longer merges classifier null with user-unclear", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(route, /!classified \|\| classified\.intent === "unclear"/);
assert.match(route, /outcome === "classifier_unavailable"/);
assert.match(route, /outcome === "unclear"/);
assert.match(route, /classifierUnavailableReply/);
const unavailableAt = route.indexOf('outcome === "classifier_unavailable"');
const unclearAt = route.indexOf('outcome === "unclear"');
assert.ok(unavailableAt >= 0 && unclearAt > unavailableAt);
const unavailableBlock = route.slice(unavailableAt, unclearAt);
assert.match(unavailableBlock, /classifierUnavailableReply/);
assert.doesNotMatch(unavailableBlock, /applyRectificationChoice/);
assert.doesNotMatch(unavailableBlock, /authorizeUsage/);
assert.doesNotMatch(unavailableBlock, /record-evidence-batch|recordV10EvidenceBatch/);
});
@@ -1,14 +1,19 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
RectificationEngineError,
RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS,
RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT,
engineRequestBody,
mergeVedastroValidateIntoReceipt,
nextEngineBusyBackoffMs,
runV9CandidateScore,
runV9Diagnostics,
runV9VedastroValidate,
sanitizeAskedProbeKeysForEngine,
setEngineBusyWaitForTests,
toEngineEvents,
type V9EngineScoreResult,
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
@@ -521,3 +526,189 @@ test("engineRequestBody forwards a slow-compare column_times subset", () => {
});
assert.deepEqual(body.column_times, ["14:46", "14:47"]);
});
const SCORE_INPUT = {
baselineBirthSnapshot: SNAPSHOT,
candidateRange: RANGE,
events: toEngineEvents(EVIDENCE),
};
function stubEngineSequence(
responses: Array<{
status?: number;
body?: unknown;
retryAfter?: string;
throwError?: Error;
}>,
) {
const previous = globalThis.fetch;
let index = 0;
globalThis.fetch = (async () => {
const next = responses[index] ?? responses.at(-1);
index += 1;
if (!next) throw new Error("no stubbed engine response");
if (next.throwError) throw next.throwError;
const status = next.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(next.retryAfter ? { "Retry-After": next.retryAfter } : {}),
json: async () => next.body,
};
}) as unknown as typeof fetch;
return {
calls: () => index,
restore() {
globalThis.fetch = previous;
},
};
}
function captureWarnings<T>(run: () => Promise<T>): Promise<{ result: PromiseSettledResult<T>; warnings: unknown[] }> {
const warnings: unknown[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args);
};
return run()
.then((value) => ({ result: { status: "fulfilled" as const, value }, warnings }))
.catch((reason: unknown) => ({ result: { status: "rejected" as const, reason }, warnings }))
.finally(() => {
console.warn = original;
});
}
test("busy backoff is a named cap of two waits and six seconds", () => {
assert.equal(RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT, 2);
assert.equal(RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS, 6_000);
assert.equal(nextEngineBusyBackoffMs({
retryAfterSeconds: 2,
retriesUsed: 0,
backoffUsedMs: 0,
}), 2_000);
assert.equal(nextEngineBusyBackoffMs({
retryAfterSeconds: 2,
retriesUsed: 1,
backoffUsedMs: 2_000,
}), 2_000);
assert.equal(nextEngineBusyBackoffMs({
retryAfterSeconds: 2,
retriesUsed: 2,
backoffUsedMs: 4_000,
}), null);
assert.equal(nextEngineBusyBackoffMs({
retryAfterSeconds: 10,
retriesUsed: 0,
backoffUsedMs: 0,
}), 6_000);
assert.equal(nextEngineBusyBackoffMs({
retryAfterSeconds: 2,
retriesUsed: 1,
backoffUsedMs: 6_000,
}), null);
const client = readFileSync(
new URL("../src/lib/rectification-agentic/v9/engine-client.ts", import.meta.url),
"utf8",
);
assert.match(client, /RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT = 2/);
assert.match(client, /RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS = 6_000/);
assert.match(client, /retriesUsed >= RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT/);
});
test("score classifies 429, 500, timeout, and bad JSON separately and only 429 retries", async () => {
const waits: number[] = [];
setEngineBusyWaitForTests(async (ms) => {
waits.push(ms);
});
try {
const busyThenOk = stubEngineSequence([
{ status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" },
{ status: 200, body: ENGINE_SCORE_RESPONSE_V2 },
]);
const busyOk = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT));
busyThenOk.restore();
assert.equal(busyOk.result.status, "fulfilled");
if (busyOk.result.status === "fulfilled") {
assert.equal(busyOk.result.value.representativeTime, "04:50");
}
assert.equal(waits.length, 1);
assert.equal(waits[0], 2_000);
assert.equal(busyThenOk.calls(), 2);
const busyLog = JSON.stringify(busyOk.warnings[0]);
assert.match(busyLog, /rectification_engine_failed/);
assert.match(busyLog, /"busy"/);
assert.match(busyLog, /"httpStatus":429/);
assert.doesNotMatch(busyLog, /1997-08-08|latitude|token|JWT/);
waits.length = 0;
const http = stubEngineSequence([{ status: 500, body: { error: "boom" } }]);
const httpResult = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT));
http.restore();
assert.equal(httpResult.result.status, "rejected");
if (httpResult.result.status === "rejected") {
const error = httpResult.result.reason;
assert.ok(error instanceof RectificationEngineError);
assert.equal(error.kind, "http_error");
assert.equal(error.httpStatus, 500);
}
assert.equal(waits.length, 0);
assert.equal(http.calls(), 1);
assert.match(JSON.stringify(httpResult.warnings[0]), /"http_error"/);
const timeout = stubEngineSequence([{
throwError: Object.assign(new Error("aborted"), { name: "TimeoutError" }),
}]);
const timeoutResult = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT));
timeout.restore();
assert.equal(timeoutResult.result.status, "rejected");
if (timeoutResult.result.status === "rejected") {
const error = timeoutResult.result.reason;
assert.ok(error instanceof RectificationEngineError);
assert.equal(error.kind, "timeout");
}
assert.equal(waits.length, 0);
assert.match(JSON.stringify(timeoutResult.warnings[0]), /"timeout"/);
const bad = stubEngineSequence([{ status: 200, body: null }]);
const badResult = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT));
bad.restore();
assert.equal(badResult.result.status, "rejected");
if (badResult.result.status === "rejected") {
const error = badResult.result.reason;
assert.ok(error instanceof RectificationEngineError);
assert.equal(error.kind, "bad_payload");
assert.equal(error.code, "engine_invalid_response");
}
assert.equal(waits.length, 0);
assert.match(JSON.stringify(badResult.warnings[0]), /"bad_payload"/);
} finally {
setEngineBusyWaitForTests(null);
}
});
test("busy retries that still fail keep the busy kind", async () => {
const waits: number[] = [];
setEngineBusyWaitForTests(async (ms) => {
waits.push(ms);
});
const stub = stubEngineSequence([
{ status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" },
{ status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" },
{ status: 429, body: { error: "ERR_COMPUTE_BUSY" }, retryAfter: "2" },
]);
try {
const result = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT));
assert.equal(result.result.status, "rejected");
if (result.result.status === "rejected") {
const error = result.result.reason;
assert.ok(error instanceof RectificationEngineError);
assert.equal(error.kind, "busy");
}
assert.equal(waits.length, 2);
assert.equal(stub.calls(), 3);
} finally {
stub.restore();
setEngineBusyWaitForTests(null);
}
});
@@ -793,7 +793,14 @@ test("compare-candidates rescores when stored policy lags the live engine", asyn
(tools["rectification-compare-candidates"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({ caseId: CASE_ID }),
(error: unknown) => error instanceof Error && error.message.includes("stale policy must rescore"),
(error: unknown) => {
// 原值: 原始 Error 文案 "stale policy must rescore" 原样冒出
// 新值: 引擎客户端把非 2xx / 抛错收成 RectificationEngineErrorhttp_error
// 原因: BUG-723 不得把失败原因压平或把上游原文泄漏给工具层
return error instanceof Error
&& error.name === "RectificationEngineError"
&& scoreRequested;
},
);
assert.equal(scoreRequested, true);
} finally {
+92 -1
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -10,7 +11,15 @@ import {
streamToolNames,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import {
RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S,
RECTIFICATION_MIN_RETRY_ATTEMPT_MS,
RECTIFICATION_RUN_BUDGET_MS,
runV9AgentTurn,
type V9AgentRunOptions,
} from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
SESSION_ID,
@@ -1851,3 +1860,85 @@ test("does not create a 2002 user message from a 2020 assistant suggestion", asy
assert.doesNotMatch(result.answerText, /2002/);
assert.equal(result.answerText, "记下了,2020年4月开始实习。");
});
test("whole-run budget is less than both route maxDuration values", () => {
const agentRoute = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const regenerateRoute = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url),
"utf8",
);
const agentMax = Number(agentRoute.match(/export const maxDuration = (\d+)/)?.[1]);
const regenerateMax = Number(regenerateRoute.match(/export const maxDuration = (\d+)/)?.[1]);
assert.equal(agentMax, RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S);
assert.equal(regenerateMax, RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S);
assert.ok(RECTIFICATION_RUN_BUDGET_MS < agentMax * 1000);
assert.ok(RECTIFICATION_RUN_BUDGET_MS < regenerateMax * 1000);
assert.ok(RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS <= RECTIFICATION_RUN_BUDGET_MS);
assert.ok(RECTIFICATION_MIN_RETRY_ATTEMPT_MS > 0);
const budget = readFileSync(new URL("../src/lib/rectification-run-budget.ts", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const labels = readFileSync(new URL("../src/lib/rectification-activity-labels.ts", import.meta.url), "utf8");
assert.match(budget, /export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/);
assert.doesNotMatch(agentRun, /210_000/);
assert.doesNotMatch(labels, /210_000/);
});
test("a retryable first attempt that exhausts the run budget does not start a second attempt", async () => {
let buildCount = 0;
const { options, emitted, billing } = runOptions({
runBudgetMs: 40,
minRetryAttemptMs: 80,
expectedWrite: "evidence",
buildAgent: async () => {
buildCount += 1;
return 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: "记下了:2016 年 3 月入学。" }),
chunk("finish"),
]) as never;
},
});
const result = await runV9AgentTurn(options);
assert.equal(buildCount, 1);
assert.equal(result.ok, true);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.ok(result.phases.includes("answer.host_fallback"));
assert.match(result.answerText, /还没记上|再发|现有材料|再说一次/);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
});
test("failed evidence rescore tells the user this turn did not re-compare", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => 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("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_count: 1,
accepted_recaps: [{ display_date_label: "2016年3月", event_phrase: "入学" }],
rescore: { status: "failed", error_code: "engine_request_failed", error_kind: "busy" },
},
}),
chunk("text-delta", { text: "记下了:2016 年 3 月入学。范围在收窄。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /这次没有重新比较/);
assert.match(result.answerText, /记下了/);
assert.doesNotMatch(result.answerText, /收窄/);
assert.equal(result.answerText.includes(RECTIFICATION_USER_COPY.rescoreSkipped), true);
const spoken = emitted.filter((event) => event.type === "answer.delta").map((event) => event.text).join("");
assert.match(spoken, /这次没有重新比较/);
});
@@ -43,7 +43,10 @@ test("declared birth-window utterances parse ranges and around-times", () => {
test("choice-focus declared window is answered from the route before the model", () => {
const intercept = ROUTE.indexOf('if (action === "message" && parseDeclaredBirthWindow');
const selectedModel = ROUTE.indexOf("const selectedModel = isStructuredChoice");
const classifyCall = ROUTE.indexOf("await classifyRectificationTurnIntent");
// 原值: await classifyRectificationTurnIntent
// 新值: await classifyTurnIntentWithRetry(点选题与 discriminator 同一入口)
// 原因: BUG-722 分类器失败不得再走「用户说不清」
const classifyCall = ROUTE.indexOf("await classifyTurnIntentWithRetry");
assert.ok(intercept > 0);
assert.ok(selectedModel > intercept);
assert.ok(classifyCall > intercept);