fix(rectification): 分类器失败、引擎忙、整轮超时不再归因错
点选题把分类器两次异常说成用户没说清(BUG-722);引擎 429 被压成坏了且重算静默失败(BUG-723);两次 attempt 总预算大于路由 maxDuration(BUG-724)。本单只改归因:classifier_unavailable 请用户重发、busy 分档并可见「这次没有重新比较」、整轮 225s 预算不够则 host fallback。不改计费、分类模型、并发闸门。
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-16 — 校正时系统没接上会直说,记下经历后没重算也会说明
|
||||
|
||||
生时校正里,如果这边没判断出你在答哪一问,会请你把刚才那句再发一次,而不是说你没说清。记下经历后如果这次没能重新比较候选,会告诉你经历已经记下、稍后再比,不会假装范围已经在收。Skill 版本不变。
|
||||
|
||||
## 2026-09-15 — 外部占星服务额度用尽时马上说明,不再空等
|
||||
|
||||
解读若要用外部占星服务、而免费额度已经用完,会马上说明这次没拿到,不再空等几分钟。Skill 版本不变。
|
||||
|
||||
@@ -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 / stream(50) / 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 / 抛错收成 RectificationEngineError(http_error)
|
||||
// 原因: BUG-723 不得把失败原因压平或把上游原文泄漏给工具层
|
||||
return error instanceof Error
|
||||
&& error.name === "RectificationEngineError"
|
||||
&& scoreRequested;
|
||||
},
|
||||
);
|
||||
assert.equal(scoreRequested, true);
|
||||
} finally {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user