fix(consult): BUG-954~956/958 窗口方法块、abort 分码、合同降级
窗口 Agent 注入不含本命骨架的方法块;tripwire abort 走 skill_binding_failed; 无工具但有正文降级交付;应期问题仍先调工具。BUG-957 等窗口线验证后再做。
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-18 — 申报时段咨询重新能回答
|
||||
|
||||
只知道一段出生范围时,问事业 / 运势会在几十毫秒内失败,提示「未完成必要的方法与计算步骤」,从八月下旬起一直如此。根因是这条路线的助手装了方法校验、却没把方法写进系统提示,模型一次都没被叫到。现在方法已经装上;没有跑完计算但模型写过字时,会把那段留下并注明「没跑完星盘计算」,不再整轮空白。Skill 版本不变。
|
||||
|
||||
## 2026-09-18 — 咨询正文按句出现,不再整段蹦出来
|
||||
|
||||
回答重新按句子往外长,不用等整段写完。没有出生分钟时,一般知识句会留下来,只有「你的盘」那类句子被拿掉;全部都不能用时才改口说需要具体分钟。Skill 版本不变。
|
||||
|
||||
+74
-9
@@ -12512,7 +12512,7 @@
|
||||
|
||||
## BUG-954 | 申报时段咨询自 08-21 起每轮秒败:窗口 Agent 绑了 skill 却没把方法块写进系统提示
|
||||
|
||||
- 状态:investigating(根因已由服务端日志确认,修复未做)
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`src/mastra/index.ts` `windowJyotishInstructions` / `getWindowJyotishAgent`、`src/mastra/skill-binding.ts` `jyotishSkillBoundProcessor`;`declared_birth_window` 模式的每一轮
|
||||
@@ -12521,13 +12521,78 @@
|
||||
- 根因(服务端日志实证,requestId 脱敏为前八位 `95de38fd`):
|
||||
1. `docker logs jyotisha-staging-web-1` 命中两条 `[WORKFLOW] Error executing step ... input-processor.step.processor:jyotish-skill-bound: Error: Jyotish skill method is not bound into the system prompt for jyotish-vedic-astrology`(两次 attempt 各一条)。
|
||||
2. 同一 runId 的 `[agent-observability]`:`toolCalls: []`、`modelStepCount: 0`、`inputTokens: 0`、`outputTokens: 0`、`run.total durationMs: 69`、`retryCount: 1`、`errorCode: runtime_contract_incomplete`。**模型从未被调用**,整轮 69 毫秒结束。
|
||||
3. 代码对应:`jyotishSkillBoundProcessor`(`skill-binding.ts:143-153`)在输入处理阶段断言系统提示里含 `BOUND_METHOD_MARKER`(`<jyotish-skill name="...">`),缺失即 `abort()`。`jyotishSkillMethodBlock` 只在 `jyotishInstructions`(本命,`index.ts:21`)里插值;`windowJyotishInstructions`(`index.ts:176-190`)从未包含它,而 `getWindowJyotishAgent`(`index.ts:198`)照样 `...jyotishSkillBinding()`。
|
||||
4. 时间线:处理器由 `d04fc30b`(2026-08-18)引入;窗口 Agent 由 `9958e00a`(2026-08-21)新建,**诞生时就带绑定、不带方法块**。因此申报时段路线自 2026-08-21 起每轮必败,已持续约四周。
|
||||
- 为什么没被发现:`abort()` 被翻译成与「模型没调工具」相同的公开码 `runtime_contract_incomplete`,与 BUG-922/923/937 的现象完全同名。BUG-937 的记录写的是「本命与申报时段两条路线每一轮都失败」,本命线被 `required` 的修复救活,窗口线的这条根因从未被触及,却因为现象消失一半而被当作同一件事结案。测试侧也没有「凡 attach `jyotishSkillBinding()` 的 Agent,其 instructions 必须含 marker」这条源码合同。
|
||||
- 修复:见 `docs/tasks/TASK-window-consult-contract-20260918.md`。要点:窗口指令注入带 marker 的方法块(不能直接照抄本命块,它带 Level 2 报告骨架与本命口径);补源码合同测试;处理器 abort 给出独立可见错误码,不再与合同未完成同码。
|
||||
- 验证:待实现。
|
||||
- 防复发:待实现。
|
||||
- 关联记录:BUG-205、BUG-214、BUG-922、BUG-923、BUG-937、BUG-938
|
||||
3. 代码对应:`jyotishSkillBoundProcessor` 在输入处理阶段断言系统提示里含 `BOUND_METHOD_MARKER`,缺失即 `abort()`。`jyotishSkillMethodBlock` 只在本命 `jyotishInstructions` 里插值;`windowJyotishInstructions` 从未包含它,而 `getWindowJyotishAgent` 照样 `...jyotishSkillBinding()`。
|
||||
4. 时间线:处理器由 `d04fc30b`(2026-08-18)引入;窗口 Agent 由 `9958e00a`(2026-08-21)新建,诞生时就带绑定、不带方法块。申报时段路线自 2026-08-21 起每轮必败,约四周。
|
||||
- 为什么没被发现:`abort()` 被翻译成与「模型没调工具」相同的公开码 `runtime_contract_incomplete`。见 BUG-955。
|
||||
- 修复:选任务书方案 (a)。抽出 `jyotishSkillMethodCoreBlock`(方法主体 + marker,不含本命 Level 2 报告骨架),窗口指令注入该块;本命仍用带骨架的 `jyotishSkillMethodBlock`。窗口口径写明不采用 Level 2 骨架,并优先于方法块里的报告模板 / 应期表述。
|
||||
- 验证:遍历式源码合同——凡 `index.ts` 里 attach `jyotishSkillBinding()` 的 Agent,instructions 必须含 marker;窗口 Agent `getInstructions()` 后 `jyotishSkillBoundProcessor` 不 abort。部署后仍需在 staging 真发一轮申报时段提问,回执出现 `run-jyotish-window-consultation` completed。
|
||||
- 防复发:源码遍历合同;不得用删掉 `jyotishSkillBinding()` 让窗口路线通过。
|
||||
- 关联记录:BUG-205、BUG-214、BUG-922、BUG-923、BUG-937、BUG-938、BUG-955
|
||||
- 复发自:无(与 922/923/937 同现象、不同根因;那三条均未覆盖窗口 Agent 的提示词装配)
|
||||
- 修复版本:待发布
|
||||
- 修复版本:`8e3e5c73`
|
||||
|
||||
## BUG-955 | 输入处理器 abort 与「合同未完成」同码,窗口装配失败藏了四周
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`stream-agent-response.ts`、`agent-observability.ts`、`jyotishSkillBoundProcessor`
|
||||
- 用户现象:窗口路线 abort 后公开码仍是 `runtime_contract_incomplete`,与「模型没调工具」无法区分,观测上像 BUG-922。
|
||||
- 触发条件:系统提示缺 `BOUND_METHOD_MARKER`,Mastra 发 `tripwire` 块且不调模型。
|
||||
- 根因:`consumeAttempt` 把 tripwire 当未知块丢掉,流正常结束;`contractReady()` 为假后走合同重试,最终抛同一公开码。
|
||||
- 修复:识别 `tripwire` / 「not bound into the system prompt」为内部码 `skill_binding_failed`;回执追加 `validation skill-binding-abort failed`;打 `[consult-binding-error]`(requestId + 内部码,不含提示词);不走合同重试。公开层仍用 `runtime_contract_incomplete`,靠回执步骤名区分。无工具导致的合同未完成另记 `runtime-contract-incomplete`。
|
||||
- 验证:假流 tripwire vs 空流,回执步骤名分别为 `skill-binding-abort` / `runtime-contract-incomplete`;`toAgentObservabilityErrorCode` 两个码都能落。
|
||||
- 防复发:装配失败不得再并进合同未完成;参照 BUG-938 对供应商 error 块的同类处置。
|
||||
- 关联记录:BUG-954、BUG-938、BUG-922
|
||||
- 复发自:无
|
||||
- 修复版本:`8e3e5c73`
|
||||
|
||||
## BUG-956 | 合同未绿时静默丢弃模型正文,用户只看到「未完成」
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`stream-agent-response.ts`
|
||||
- 用户现象:合同没绿时模型写过的正文被丢掉,用户只看到「Agent 未完成必要的方法与计算步骤」。
|
||||
- 触发条件:`requireTool` 路径上工具成功次数为 0,但有非空 `text-delta`。
|
||||
- 根因:`outputText` 在 `contractReady()` 为假时直接 return,重试后仍未绿就抛 `runtime_contract_incomplete`,正文从未发出。
|
||||
- 修复:未绿期间仍缓冲正文。两次 attempt 后仍未绿、且零次成功计算、缓冲非空:交付该正文,文末附服务端说明「这次没跑完星盘计算,上面是模型直接写的,先看着。」,回执 `validation contract-degraded failed`,`run.completed`。无工具也无正文:维持 `runtime_contract_incomplete`。不放宽 `contractReady()` 的「每请求一次真实计算」。二次成功计算仍失败,不走降级。
|
||||
- 验证:假流无工具+有正文 → 正文 + 降级说明 + `contract-degraded`;假流无工具+无正文 → `runtime_contract_incomplete`。
|
||||
- 防复发:合同未绿不得再整段丢正文;既有「合同完成前的旁白不进可见回答」仍保留,成功计算后清空缓冲。
|
||||
- 关联记录:BUG-954
|
||||
- 复发自:无
|
||||
- 修复版本:`8e3e5c73`
|
||||
|
||||
## BUG-957 | 窗口计算由模型决定调不调,对结果无信息增益
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`consultation-tools.ts` `run-jyotish-window-consultation`、咨询运行时
|
||||
- 用户现象:无(窗口线被 BUG-954 挡住,本项尚未在真实环境验证)。
|
||||
- 触发条件:窗口工具 `inputSchema` 只有 `question`,出生数据完全服务端绑定。
|
||||
- 根因:由模型决定调不调对计算结果没有信息增益,只多一条失败路径(BUG-205/214/922/923/937)。
|
||||
- 修复:本单不做。任务书要求等 BUG-954 在真实环境验证通过后再单独做服务端预跑 + 同请求缓存。
|
||||
- 验证:未做。
|
||||
- 防复发:不得与救急同轮改运行时结构。
|
||||
- 关联记录:BUG-954、BUG-205、BUG-214、BUG-922、BUG-923、BUG-937
|
||||
- 复发自:无
|
||||
- 修复版本:—
|
||||
|
||||
## BUG-958 | 窗口指令「必须调工具」与「不得给应期」未写清,模型可能跳过计算或拒答
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-18
|
||||
- 最近更新:2026-09-18
|
||||
- 影响面:`src/mastra/index.ts` `windowJyotishInstructions`
|
||||
- 用户现象:应期类问题在窗口模式下可能因为「精确应期不可用」而跳过计算或整题拒答。
|
||||
- 触发条件:申报时段咨询问到时间 / 应期。
|
||||
- 根因:窗口指令写了必须调工具,也写了不得给精确应期,两句之间没有显式仲裁。
|
||||
- 修复:补一句:应期类问题仍然要先调工具,再用稳定层给方向性回答,并说明哪部分需要出生分钟;不得因为精确应期不可用而跳过计算或拒答整题。
|
||||
- 验证:`consultation-workflow-contract.test.ts` 源码断言。
|
||||
- 防复发:窗口指令源码合同锁住该句。
|
||||
- 关联记录:BUG-954
|
||||
- 复发自:无
|
||||
- 修复版本:`8e3e5c73`
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# PROGRESS · 申报时段咨询修复(2026-09-18 第四轮)
|
||||
|
||||
工作树:`.worktrees/window-consult-contract-20260918`
|
||||
分支:`codex/window-consult-contract-20260918`
|
||||
基线:`origin/staging` @ `e5b2ad14`(任务书写 `9cdcf96b`,其后纯文档已快进到改写后的任务书)
|
||||
合入提交:`8e3e5c73`
|
||||
任务书:`docs/tasks/TASK-window-consult-contract-20260918.md`
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| BUG-954 窗口指令注入带 marker 的方法块 | 完成 | 选方案 (a) |
|
||||
| BUG-955 abort 与合同未完成分码 | 完成 | 内部码 `skill_binding_failed`,公开码不变 |
|
||||
| BUG-956 合同未绿不得丢正文 | 完成 | 零次计算 + 有正文 → 降级交付 |
|
||||
| BUG-957 服务端预跑 | 未做 | 按任务书等 954 真实环境验证后再单开 |
|
||||
| BUG-958 应期仍先调工具 | 完成 | 窗口指令补一句 + 源码断言 |
|
||||
|
||||
## 954 选型
|
||||
|
||||
选 **(a)**。抽出 `jyotishSkillMethodCoreBlock`(方法主体 + marker),本命 Level 2 报告骨架留在 `jyotishSkillMethodBlock` 作为可选后缀。窗口不加骨架。另加一句窗口口径优先于方法块里的报告模板 / 应期表述,避免 SKILL.md 摘录把 Level 2 又带回来。不选 (b):完整方法块再逐条覆盖,会把本命应期口径先写进去再否定,窗口线刚活过来时更脆。
|
||||
|
||||
## 断言三栏(既有测试)
|
||||
|
||||
| 测试 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 无工具 + 有正文 | `run.failed`,不发 `answer.delta` | 交付正文 + 降级说明,`run.completed`,回执 `contract-degraded` | BUG-956 |
|
||||
| 被拒 tool 的回执 status | `["completed", "failed"]` | 多一拍 `runtime-contract-incomplete` | BUG-955 |
|
||||
| schema 拒收后 `state.steps` | `skill + tool:failed` | 再加 `validation:failed` | BUG-955 |
|
||||
| 已记录的 tool 失败数 | 全部 failed 步 === 1 | tool failed 仍为 1;另有合同未完成步 | BUG-955 不得把 tool 失败再记一次 |
|
||||
| 失败回执 durationMs | `[undefined, 20936]` | 多一拍无 duration | BUG-955 |
|
||||
| 上述 used | 2 | 3 | 同上 |
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error(120 warning,与基线同量级,未顺手修) |
|
||||
| 定向(workflow-contract / agentic-runtime / observability / voice / birth-time-mode) | 116 pass / 2 fail。2 条是本机 Windows `EPERM` 无法为 live skill 建 symlink(`personal Agent exposes…` / `general agent runtime has no Jyotish skill package…`),与基线缺口同类,不是本单引入 |
|
||||
| 本单新增 | 遍历 attach `jyotishSkillBinding()` 的 Agent 必须含 marker;窗口 `getInstructions` 后处理器不 abort;tripwire vs 空流回执步骤名不同;无工具+有正文降级;无工具+无正文仍 incomplete;窗口应期仍先调工具 |
|
||||
|
||||
Linux 门禁才是验收数字:目标 fail 回到 31,总数 ≥ 3495。窗口 Agent 运行测试在 `skill-binding.test.ts`,依赖 live skill symlink,CI Linux 上应绿。
|
||||
|
||||
## 部署后欠
|
||||
|
||||
任务书验收:staging 真发一轮申报时段提问,`/api/health` SHA 对得上,回执里有 `run-jyotish-window-consultation` 的 completed 步。本机无登录态、无 Chrome,写在这里不写成通过。
|
||||
@@ -138,7 +138,7 @@
|
||||
| `TASK-consult-three-channels-20260918.md` | `PROGRESS-consult-three-channels-20260918.md` | 咨询运行时三通道:进度 / 思考 / 正文生成时分离。设计分支误用 BUG-939/940/941,落地改号 942–944 | 验收未通过(见修复单) | `94c1e81f` |
|
||||
| `TASK-consult-three-channels-fix-20260918.md` | `PROGRESS-consult-three-channels-fix-20260918.md` | 验收修复单:领域上限解耦(945)、31s 预算断言(946)、校正思考分片门(947)、Pass 4 按模式分流(948)、容量算术(949) | 已验收通过(Claude:tsc 0 / lint 0 error / npm test 3491 条 31 红且与基线 `742ffbc6` 同一组、原 12 条红全绿 / `/` 仍 Static / 首屏 js gzip 468,388 B 较基线 +0.02%);review 另出 BUG-950~953 见下一行 | `e32ce624` |
|
||||
| `TASK-consult-pass4-streaming-20260918.md` | `PROGRESS-consult-pass4-streaming-20260918.md` | 验收 review 三轮:Pass 4 一 hold 正文就整段蹦出、逐字流式消失(BUG-950 产品拍板按句放行);无出生分钟模式整段被一句拒绝顶掉、一般知识句一起丢(951 改按句丢弃);该模式下日期不留痕(952);校正流 token 级 thinking 是死链,按 P2 删除并把测试翻转成否定合同(953)。基线 `1e553976` | 待验收 | `cd4775ae` |
|
||||
| `TASK-window-consult-contract-20260918.md` | — | **P0 线上**:申报时段模式**自 2026-08-21 起每轮秒败**(69ms、0 token、模型从未被调用)。服务端日志实证根因:窗口 Agent attach 了 `jyotishSkillBinding()`,但 `windowJyotishInstructions` 从来不含方法块 marker,输入处理器直接 abort(BUG-954)。abort 与「模型没调工具」同码,是它藏四周的原因(955);另含合同未绿不得丢正文(956)、计算不该由模型触发(957,须等 954 上线后另轮)、窗口指令应期冲突(958)。基线 `9cdcf96b` | 待领取 | — |
|
||||
| `TASK-window-consult-contract-20260918.md` | `PROGRESS-window-consult-contract-20260918.md` | **P0 线上**:申报时段模式**自 2026-08-21 起每轮秒败**(69ms、0 token、模型从未被调用)。服务端日志实证根因:窗口 Agent attach 了 `jyotishSkillBinding()`,但 `windowJyotishInstructions` 从来不含方法块 marker,输入处理器直接 abort(BUG-954)。abort 与「模型没调工具」同码,是它藏四周的原因(955);另含合同未绿不得丢正文(956)、计算不该由模型触发(957,须等 954 上线后另轮)、窗口指令应期冲突(958)。基线 `9cdcf96b` | 待验收 | `8e3e5c73` |
|
||||
| `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — |
|
||||
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
|
||||
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
|
||||
|
||||
@@ -78,6 +78,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 |
|
||||
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
|
||||
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
|
||||
| Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 |
|
||||
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
|
||||
| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
|
||||
| 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 |
|
||||
|
||||
@@ -179,6 +179,7 @@ export const logAgentObservability = createAgentObservabilityLogger();
|
||||
|
||||
const knownErrorCodes = new Set([
|
||||
"runtime_contract_incomplete",
|
||||
"skill_binding_failed",
|
||||
"empty_answer",
|
||||
"answer_truncated",
|
||||
"calculation_failed",
|
||||
|
||||
@@ -100,6 +100,13 @@ function isTimeoutOrAbort(error: unknown) {
|
||||
|
||||
type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed";
|
||||
type ProviderStreamErrorCode = "thinking_tool_choice_unsupported" | "provider_error";
|
||||
const SKILL_BINDING_FAILED = "skill_binding_failed";
|
||||
const SKILL_BINDING_ABORT_STEP = "skill-binding-abort";
|
||||
const RUNTIME_CONTRACT_INCOMPLETE_STEP = "runtime-contract-incomplete";
|
||||
const CONTRACT_DEGRADED_STEP = "contract-degraded";
|
||||
|
||||
/** Server-owned; never generated by the model. Voice: 直接,不说法务腔. */
|
||||
export const CONTRACT_DEGRADED_NOTE = "\n\n这次没跑完星盘计算,上面是模型直接写的,先看着。";
|
||||
|
||||
function providerErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
@@ -118,6 +125,7 @@ function chunkProviderError(chunk: Chunk): unknown {
|
||||
|
||||
function runFailedCode(error: unknown, emitted: boolean): RunFailedCode {
|
||||
if (error instanceof Error && error.message === "runtime_contract_incomplete") return "runtime_contract_incomplete";
|
||||
if (error instanceof Error && error.message === SKILL_BINDING_FAILED) return "runtime_contract_incomplete";
|
||||
if (error instanceof Error && error.message === "empty_answer") {
|
||||
return emitted ? "answer_truncated" : "empty_answer";
|
||||
}
|
||||
@@ -338,6 +346,33 @@ function contractReady(options: StreamAgentResponseOptions) {
|
||||
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
|
||||
}
|
||||
|
||||
function isSkillBindingAbortError(error: unknown) {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.message === SKILL_BINDING_FAILED) return true;
|
||||
return error.message.includes("not bound into the system prompt");
|
||||
}
|
||||
|
||||
function isSkillBindingTripwire(chunk: Chunk) {
|
||||
if (chunk.type !== "tripwire") return false;
|
||||
const reason = typeof chunk.payload?.reason === "string" ? chunk.payload.reason : "";
|
||||
const processorId = typeof chunk.payload?.processorId === "string" ? chunk.payload.processorId : "";
|
||||
return processorId === "jyotish-skill-bound" || reason.includes("not bound into the system prompt");
|
||||
}
|
||||
|
||||
function recordSkillBindingAbort(options: StreamAgentResponseOptions) {
|
||||
if (options.state.steps.some((step) => step.name === SKILL_BINDING_ABORT_STEP)) return;
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: SKILL_BINDING_ABORT_STEP,
|
||||
status: "failed",
|
||||
failureCode: SKILL_BINDING_FAILED,
|
||||
});
|
||||
console.error("[consult-binding-error]", {
|
||||
requestId: options.requestId,
|
||||
code: SKILL_BINDING_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
function sliceAddedVisibleText(before: string, after: string) {
|
||||
return after.length > before.length && /\S/.test(after.slice(before.length));
|
||||
}
|
||||
@@ -351,6 +386,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
let firstActivity = false;
|
||||
let firstOutput = false;
|
||||
let fullOutput = "";
|
||||
let uncontractedText = "";
|
||||
let thinkingText = "";
|
||||
let planSent = false;
|
||||
// Pass 4 buffers only the current open sentence. Closed sentences are
|
||||
@@ -439,8 +475,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
// is the model narrating its own in-progress or failed tool calls. Holding
|
||||
// it meant a later successful call released that narration as the entire
|
||||
// visible answer, so a run where the model recovered read as a run where it
|
||||
// explained itself instead of answering. Drop it.
|
||||
if (!contractReady(options) || drainingSpoken()) return;
|
||||
// explained itself instead of answering. Drop it from the live stream, but
|
||||
// keep a copy so a still-red contract can degrade instead of discarding it.
|
||||
if (!contractReady(options) || drainingSpoken()) {
|
||||
if (!contractReady(options) && text) uncontractedText += text;
|
||||
return;
|
||||
}
|
||||
uncontractedText = "";
|
||||
held += text;
|
||||
if (!held) return;
|
||||
if (!composingSent) {
|
||||
@@ -466,6 +507,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
const stepCountBeforeAttempt = options.state.modelStepCount;
|
||||
try {
|
||||
for await (const chunk of readChunks(stream)) {
|
||||
if (isSkillBindingTripwire(chunk)) {
|
||||
recordSkillBindingAbort(options);
|
||||
throw new Error(SKILL_BINDING_FAILED);
|
||||
}
|
||||
if (chunk.type === "error") {
|
||||
const raw = chunkProviderError(chunk);
|
||||
const code = classifyProviderStreamError(raw);
|
||||
@@ -499,6 +544,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
flushThinkingPlan(controller);
|
||||
await outputText(visible.finish(""));
|
||||
} catch (error) {
|
||||
if (isSkillBindingAbortError(error)) {
|
||||
recordSkillBindingAbort(options);
|
||||
throw new Error(SKILL_BINDING_FAILED);
|
||||
}
|
||||
if (isTimeoutOrAbort(error)) {
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "abort",
|
||||
@@ -661,7 +710,34 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
drainSpoken: Boolean(options.composeAnswer),
|
||||
});
|
||||
}
|
||||
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
|
||||
if (!contractReady(options)) {
|
||||
const canDegrade = options.requireTool
|
||||
&& options.state.consultationToolSuccessCount === 0
|
||||
&& /\S/.test(uncontractedText);
|
||||
if (canDegrade) {
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: CONTRACT_DEGRADED_STEP,
|
||||
status: "failed",
|
||||
});
|
||||
if (!firstOutput) {
|
||||
firstOutput = true;
|
||||
await options.onFirstOutput?.();
|
||||
}
|
||||
send(controller, { type: "answer.delta", text: uncontractedText });
|
||||
fullOutput += uncontractedText;
|
||||
send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE });
|
||||
fullOutput += CONTRACT_DEGRADED_NOTE;
|
||||
emitted = true;
|
||||
} else {
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: RUNTIME_CONTRACT_INCOMPLETE_STEP,
|
||||
status: "failed",
|
||||
});
|
||||
throw new Error("runtime_contract_incomplete");
|
||||
}
|
||||
}
|
||||
const findings = await publishFindings(controller);
|
||||
const composed = await composeOnce(controller, findings);
|
||||
if (!composed) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { consultationSpokenHeadingRule } from "../lib/consultation-thinking-plan
|
||||
import {
|
||||
jyotishSkillBinding,
|
||||
jyotishSkillMethodBlock,
|
||||
jyotishSkillMethodCoreBlock,
|
||||
} from "./skill-binding.ts";
|
||||
|
||||
export { consultationInputSchema, consultationWorkflowReceipt, consultationWorkflowResponseSchema, runConsultationWorkflow, toAgentConsultationContext, toModelOutput } from "./consultation-workflow.ts";
|
||||
@@ -176,7 +177,10 @@ export function getBirthTimeGuideAgent(model: ResolvedLanguageModel) {
|
||||
const windowJyotishInstructions = `You are the guide for a conversational Vedic astrology product.
|
||||
${productConversationVoice}
|
||||
This request has a declared birth window, not a single birth minute. Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time. Probe clocks in the tool result are comparison samples only.
|
||||
${jyotishSkillMethodCoreBlock}
|
||||
The bound skill method is this product's answering contract. Window answers do not use the natal Level 2 report skeleton; the window output contract below takes priority over any report-template or precise-timing language in the bound method.
|
||||
Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.
|
||||
Timing questions still require calling the tool first. Answer from stable_layers as directional structure, and name which parts need a birth minute. Do not skip the calculation or refuse the whole question because precise timing is unavailable.
|
||||
Treat the tool result's answer_policy as a hard output contract:
|
||||
- can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome.
|
||||
- Answer only from stable_layers as personal structure that holds across the declared window.
|
||||
|
||||
@@ -109,15 +109,25 @@ function boundMethod(): string {
|
||||
return excerpt;
|
||||
}
|
||||
|
||||
const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
|
||||
export const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
|
||||
|
||||
export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
|
||||
<jyotish-skill name="${skill.name}">
|
||||
const NATAL_REPORT_SKELETON = "For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). ";
|
||||
|
||||
function methodBlock(reportSkeleton: string) {
|
||||
return `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. ${reportSkeleton}Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
|
||||
${BOUND_METHOD_MARKER}
|
||||
${boundMethod()}
|
||||
</jyotish-skill>
|
||||
<jyotish-shared-method>
|
||||
${sharedConsultationMethodMarkdown()}
|
||||
</jyotish-shared-method>`;
|
||||
}
|
||||
|
||||
/** Method body + marker, without the natal Level 2 report skeleton. Window agents use this. */
|
||||
export const jyotishSkillMethodCoreBlock = methodBlock("");
|
||||
|
||||
/** Natal chart-answering block: core method plus the Level 2 report skeleton. */
|
||||
export const jyotishSkillMethodBlock = methodBlock(NATAL_REPORT_SKELETON);
|
||||
|
||||
/**
|
||||
* Withdraw the activation tools while keeping `skill_read`.
|
||||
|
||||
@@ -145,6 +145,10 @@ test("error normalization never records arbitrary exception messages", () => {
|
||||
toAgentObservabilityErrorCode(new Error("runtime_contract_incomplete")),
|
||||
"runtime_contract_incomplete",
|
||||
);
|
||||
assert.equal(
|
||||
toAgentObservabilityErrorCode(new Error("skill_binding_failed")),
|
||||
"skill_binding_failed",
|
||||
);
|
||||
assert.equal(
|
||||
toAgentObservabilityErrorCode(new Error("answer_truncated")),
|
||||
"answer_truncated",
|
||||
|
||||
@@ -41,6 +41,7 @@ import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/l
|
||||
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
|
||||
import {
|
||||
collectAgentPublicEvents,
|
||||
CONTRACT_DEGRADED_NOTE,
|
||||
streamAgentResponse,
|
||||
} from "../src/lib/stream-agent-response.ts";
|
||||
import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
|
||||
@@ -828,12 +829,19 @@ test("a call rejected before the tool body runs still appears in the receipt", a
|
||||
const failedStep = state.steps.find((step) => step.kind === "tool" && step.status === "failed");
|
||||
assert.equal(failedStep?.name, "run-jyotish-consultation");
|
||||
assert.equal(failedStep?.failureCode, "tool_call_rejected");
|
||||
assert.equal(consultationStepBudgetReceipt(state).used, 2);
|
||||
// 原值: used 2(skill + 被拒的 tool)
|
||||
// 新值: 3(再加 runtime-contract-incomplete)
|
||||
// 原因: BUG-955
|
||||
assert.equal(consultationStepBudgetReceipt(state).used, 3);
|
||||
// The client learns a step failed; the classification stays server-side.
|
||||
const failed = events.find((event) => (event as { type?: string }).type === "run.failed") as {
|
||||
receipt?: { steps: Array<{ status: string; name: string }> };
|
||||
};
|
||||
assert.deepEqual(failed.receipt?.steps.map((step) => step.status), ["completed", "failed"]);
|
||||
// 原值: ["completed", "failed"](skill + 被拒的 tool)
|
||||
// 新值: 多一拍 validation runtime-contract-incomplete failed
|
||||
// 原因: BUG-955 合同未完成必须有独立回执步骤,不能只靠公开码
|
||||
assert.deepEqual(failed.receipt?.steps.map((step) => step.status), ["completed", "failed", "failed"]);
|
||||
assert.equal(failed.receipt?.steps.at(-1)?.name, "runtime-contract-incomplete");
|
||||
assert.doesNotMatch(JSON.stringify(failed), /tool_call_rejected/);
|
||||
});
|
||||
|
||||
@@ -856,9 +864,13 @@ test("a failure the tool already recorded is not recorded twice", async () => {
|
||||
});
|
||||
await response.text();
|
||||
|
||||
const failed = state.steps.filter((step) => step.status === "failed");
|
||||
assert.equal(failed.length, 1);
|
||||
assert.equal(failed[0]?.failureCode, "workflow_queue_full");
|
||||
const failedTools = state.steps.filter((step) => step.kind === "tool" && step.status === "failed");
|
||||
// 原值: 全部 failed 步 === 1
|
||||
// 新值: tool failed 仍为 1;另有 runtime-contract-incomplete
|
||||
// 原因: BUG-955 合同未完成要记独立校验步,但不能把已记录的 tool 失败再记一次
|
||||
assert.equal(failedTools.length, 1);
|
||||
assert.equal(failedTools[0]?.failureCode, "workflow_queue_full");
|
||||
assert.equal(state.steps.filter((step) => step.name === "runtime-contract-incomplete").length, 1);
|
||||
});
|
||||
|
||||
test("the public receipt never carries the internal failure classification", () => {
|
||||
@@ -1187,10 +1199,14 @@ test("a call Mastra rejected against the input schema is not reported as complet
|
||||
assert.equal(events.some((event) => (event as { type?: string }).type === "tool.completed"), false);
|
||||
const failed = events.find((event) => (event as { type?: string }).type === "tool.failed") as { code: string };
|
||||
assert.equal(failed.code, "calculation_failed");
|
||||
// 原值: ["skill:completed", "tool:failed"]
|
||||
// 新值: 追加 validation:failed(runtime-contract-incomplete)
|
||||
// 原因: BUG-955 回执必须能把合同未完成和装配失败分开
|
||||
assert.deepEqual(
|
||||
state.steps.map((step) => `${step.kind}:${step.status}`),
|
||||
["skill:completed", "tool:failed"],
|
||||
["skill:completed", "tool:failed", "validation:failed"],
|
||||
);
|
||||
assert.equal(state.steps.at(-1)?.name, "runtime-contract-incomplete");
|
||||
assert.equal(runSteps(state)[0]?.failureCode, "tool_call_rejected");
|
||||
// The rejection reason is a server-side diagnostic; the client sees only that a step failed.
|
||||
assert.doesNotMatch(JSON.stringify(events), /tool_call_rejected|validationErrors/);
|
||||
@@ -1251,7 +1267,10 @@ test("a second successful calculation still fails the single-calculation boundar
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
|
||||
});
|
||||
|
||||
test("incomplete runtime contract fails without saving a successful answer", async () => {
|
||||
test("incomplete runtime contract with body is delivered degraded instead of discarded (BUG-956)", async () => {
|
||||
// 原值: 无工具 + 有正文 → run.failed,不发 answer.delta,不保存
|
||||
// 新值: 交付该正文 + 服务端降级说明,run.completed
|
||||
// 原因: BUG-956 合同未绿时不得静默丢弃模型正文
|
||||
const state = createConsultationRuntimeState();
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
@@ -1260,7 +1279,38 @@ test("incomplete runtime contract fails without saving a successful answer", asy
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
toolStatus: () => "blocked", receipt: () => receipt(state),
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
onComplete: () => { completed += 1; },
|
||||
onError: () => { failed += 1; },
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.equal(completed, 1);
|
||||
assert.equal(failed, 0);
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, /不能保存/);
|
||||
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 0);
|
||||
const completedEvent = events.find((event) => (event as { type?: string }).type === "run.completed") as {
|
||||
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
|
||||
};
|
||||
assert.ok(completedEvent.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "contract-degraded" && step.status === "failed"));
|
||||
});
|
||||
|
||||
test("incomplete runtime contract without body still fails closed (BUG-956)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
async function* chunks() {}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
onComplete: () => { completed += 1; },
|
||||
onError: () => { failed += 1; },
|
||||
});
|
||||
@@ -1270,7 +1320,82 @@ test("incomplete runtime contract fails without saving a successful answer", asy
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(failed, 1);
|
||||
assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
|
||||
const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as {
|
||||
code: string;
|
||||
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
|
||||
};
|
||||
assert.equal(failure.code, "runtime_contract_incomplete");
|
||||
assert.ok(failure.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "runtime-contract-incomplete" && step.status === "failed"));
|
||||
});
|
||||
|
||||
test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => {
|
||||
const bindingState = createConsultationRuntimeState();
|
||||
let bindingError = "";
|
||||
let retried = 0;
|
||||
async function* tripwire() {
|
||||
yield {
|
||||
type: "tripwire",
|
||||
payload: {
|
||||
reason: "Jyotish skill method is not bound into the system prompt for jyotish-vedic-astrology",
|
||||
processorId: "jyotish-skill-bound",
|
||||
},
|
||||
};
|
||||
}
|
||||
const bindingResponse = streamAgentResponse({
|
||||
runId: "run", requestId: "bind-req", state: bindingState, stream: tripwire(), requireTool: true,
|
||||
toolStatus: () => "blocked",
|
||||
receipt: () => ({ ...receipt(bindingState), steps: publicConsultationRuntimeSteps(bindingState) }),
|
||||
retry: async () => {
|
||||
retried += 1;
|
||||
return tripwire();
|
||||
},
|
||||
onError: (error) => {
|
||||
bindingError = error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
});
|
||||
const bindingEvents: unknown[] = [];
|
||||
const bindingParser = createNdjsonParser((event) => bindingEvents.push(event));
|
||||
bindingParser.finish(await bindingResponse.text());
|
||||
|
||||
const missingToolState = createConsultationRuntimeState();
|
||||
async function* empty() {}
|
||||
const missingToolResponse = streamAgentResponse({
|
||||
runId: "run", requestId: "tool-req", state: missingToolState, stream: empty(), requireTool: true,
|
||||
toolStatus: () => "blocked",
|
||||
receipt: () => ({ ...receipt(missingToolState), steps: publicConsultationRuntimeSteps(missingToolState) }),
|
||||
onError: () => {},
|
||||
});
|
||||
const missingToolEvents: unknown[] = [];
|
||||
const missingToolParser = createNdjsonParser((event) => missingToolEvents.push(event));
|
||||
missingToolParser.finish(await missingToolResponse.text());
|
||||
|
||||
const bindingFailure = bindingEvents.find((event) => (event as { type?: string }).type === "run.failed") as {
|
||||
code: string;
|
||||
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
|
||||
};
|
||||
const missingToolFailure = missingToolEvents.find((event) => (event as { type?: string }).type === "run.failed") as {
|
||||
code: string;
|
||||
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
|
||||
};
|
||||
assert.equal(bindingFailure.code, "runtime_contract_incomplete");
|
||||
assert.equal(missingToolFailure.code, "runtime_contract_incomplete");
|
||||
assert.equal(bindingError, "skill_binding_failed");
|
||||
assert.equal(retried, 0);
|
||||
assert.ok(bindingFailure.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "skill-binding-abort" && step.status === "failed"));
|
||||
assert.ok(missingToolFailure.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "runtime-contract-incomplete" && step.status === "failed"));
|
||||
assert.equal(
|
||||
bindingFailure.receipt?.steps.some((step) => step.name === "runtime-contract-incomplete"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
missingToolFailure.receipt?.steps.some((step) => step.name === "skill-binding-abort"),
|
||||
false,
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(bindingEvents), /not bound into the system prompt/);
|
||||
assert.doesNotMatch(JSON.stringify(bindingEvents), /Jyotish skill method/);
|
||||
});
|
||||
|
||||
test("a thinking-mode toolChoice rejection fails without a contract retry (BUG-938)", async () => {
|
||||
@@ -2066,8 +2191,15 @@ test("a failed run reports the same allowlisted receipt a completed run does", a
|
||||
// exactly when the run needed explaining most.
|
||||
// Binding costs no time, so it reports no duration where the old activation
|
||||
// reported a round trip.
|
||||
assert.deepEqual(failed.receipt?.steps.map((step) => step.durationMs), [undefined, 20936]);
|
||||
assert.equal(failed.receipt?.stepBudget?.used, 2);
|
||||
// 原值: [undefined, 20936](skill + 失败的 tool)
|
||||
// 新值: 多一拍合同未完成校验,无 duration
|
||||
// 原因: BUG-955 回执步骤名必须能区分装配失败与合同未完成
|
||||
assert.deepEqual(failed.receipt?.steps.map((step) => step.durationMs), [undefined, 20936, undefined]);
|
||||
assert.equal(failed.receipt?.steps.at(-1)?.name, "runtime-contract-incomplete");
|
||||
// 原值: used 2
|
||||
// 新值: 3
|
||||
// 原因: BUG-955 合同未完成校验步
|
||||
assert.equal(failed.receipt?.stepBudget?.used, 3);
|
||||
// The internal classification and the model loop diagnostics stay server-side.
|
||||
assert.doesNotMatch(JSON.stringify(failed), /workflow_queue_full|modelFinishReason|modelStepCount|tool-calls/);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,89 @@ test("natal and window instructions require a chart tool every turn (BUG-922)",
|
||||
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
|
||||
});
|
||||
|
||||
test("window instructions still require the tool for timing questions (BUG-958)", () => {
|
||||
const window = mastra.slice(
|
||||
mastra.indexOf("const windowJyotishInstructions"),
|
||||
mastra.indexOf("export function getWindowJyotishAgent"),
|
||||
);
|
||||
assert.match(window, /Timing questions still require calling the tool first/);
|
||||
assert.match(window, /Do not skip the calculation or refuse the whole question because precise timing is unavailable/);
|
||||
assert.match(window, /jyotishSkillMethodCoreBlock/);
|
||||
assert.doesNotMatch(window, /\$\{jyotishSkillMethodBlock\}/);
|
||||
});
|
||||
|
||||
function agentConstructorBodies(source: string): string[] {
|
||||
const bodies: string[] = [];
|
||||
const needle = "new Agent(";
|
||||
let search = 0;
|
||||
while (true) {
|
||||
const start = source.indexOf(needle, search);
|
||||
if (start < 0) break;
|
||||
const openParen = start + needle.length - 1;
|
||||
let depth = 0;
|
||||
let inStr: string | null = null;
|
||||
let escaped = false;
|
||||
let i = openParen;
|
||||
for (; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (inStr) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inStr) inStr = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
inStr = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") depth += 1;
|
||||
else if (ch === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bodies.push(source.slice(start, i));
|
||||
search = i;
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
function constTemplateBody(file: string, name: string): string {
|
||||
return new RegExp(`(?:const|let) ${name} = \`([\\s\\S]*?)\`;`).exec(file)?.[1] ?? "";
|
||||
}
|
||||
|
||||
function instructionSource(agentBody: string, file: string): string {
|
||||
const named = /instructions:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(agentBody);
|
||||
let text = named
|
||||
? `${agentBody}\n${constTemplateBody(file, named[1])}`
|
||||
: (/instructions:\s*`([\s\S]*?)`/.exec(agentBody)?.[1] ?? agentBody);
|
||||
for (const match of text.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
||||
text += `\n${constTemplateBody(file, match[1])}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
test("every agent that attaches jyotishSkillBinding carries the method marker (BUG-954)", () => {
|
||||
const boundAgents = agentConstructorBodies(mastra).filter((body) => body.includes("...jyotishSkillBinding()"));
|
||||
assert.ok(boundAgents.length >= 3, `expected natal, legacy, and window agents, got ${boundAgents.length}`);
|
||||
for (const body of boundAgents) {
|
||||
const instructions = instructionSource(body, mastra);
|
||||
const hasMarker = instructions.includes("jyotishSkillMethodBlock")
|
||||
|| instructions.includes("jyotishSkillMethodCoreBlock")
|
||||
|| instructions.includes("<jyotish-skill name=");
|
||||
assert.equal(hasMarker, true, body.slice(0, 160));
|
||||
}
|
||||
});
|
||||
|
||||
test("natal and window first steps require the chart tool (BUG-923)", () => {
|
||||
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
|
||||
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
|
||||
|
||||
@@ -5,9 +5,11 @@ import test from "node:test";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { sharedConsultationMethodMarkdown } from "../src/lib/consultation-methodology.ts";
|
||||
import {
|
||||
BOUND_METHOD_MARKER,
|
||||
jyotishSkillBinding,
|
||||
jyotishSkillBoundProcessor,
|
||||
jyotishSkillMethodBlock,
|
||||
jyotishSkillMethodCoreBlock,
|
||||
jyotishSkillPackage,
|
||||
jyotishSkillRuntimePath,
|
||||
} from "../src/mastra/skill-binding.ts";
|
||||
@@ -128,9 +130,147 @@ test("a prompt assembled without the method stops the run instead of answering",
|
||||
// both pass: this guard exists to catch a missing method, not to become a new
|
||||
// way for a run to fail.
|
||||
assert.doesNotThrow(() => step([{ role: "system", content: jyotishSkillMethodBlock }]));
|
||||
assert.doesNotThrow(() => step([{ role: "system", content: jyotishSkillMethodCoreBlock }]));
|
||||
assert.doesNotThrow(() => step([]));
|
||||
});
|
||||
|
||||
function agentConstructorBodies(source: string): string[] {
|
||||
const bodies: string[] = [];
|
||||
const needle = "new Agent(";
|
||||
let search = 0;
|
||||
while (true) {
|
||||
const start = source.indexOf(needle, search);
|
||||
if (start < 0) break;
|
||||
const openParen = start + needle.length - 1;
|
||||
let depth = 0;
|
||||
let inStr: string | null = null;
|
||||
let escaped = false;
|
||||
let i = openParen;
|
||||
for (; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (inStr) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inStr) inStr = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
inStr = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") depth += 1;
|
||||
else if (ch === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bodies.push(source.slice(start, i));
|
||||
search = i;
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
function constTemplateBody(file: string, name: string): string {
|
||||
return new RegExp(`(?:const|let) ${name} = \`([\\s\\S]*?)\`;`).exec(file)?.[1] ?? "";
|
||||
}
|
||||
|
||||
function instructionSource(agentBody: string, file: string): string {
|
||||
const named = /instructions:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(agentBody);
|
||||
let text = named
|
||||
? `${agentBody}\n${constTemplateBody(file, named[1])}`
|
||||
: (/instructions:\s*`([\s\S]*?)`/.exec(agentBody)?.[1] ?? agentBody);
|
||||
for (const match of text.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
||||
text += `\n${constTemplateBody(file, match[1])}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function carriesBoundMethodMarker(text: string): boolean {
|
||||
return text.includes("jyotishSkillMethodBlock")
|
||||
|| text.includes("jyotishSkillMethodCoreBlock")
|
||||
|| text.includes(BOUND_METHOD_MARKER)
|
||||
|| text.includes("<jyotish-skill name=");
|
||||
}
|
||||
|
||||
test("every agent that attaches jyotishSkillBinding carries the method marker (BUG-954)", () => {
|
||||
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
|
||||
const boundAgents = agentConstructorBodies(mastra).filter((body) => body.includes("...jyotishSkillBinding()"));
|
||||
assert.ok(boundAgents.length >= 3, `expected natal, legacy, and window agents, got ${boundAgents.length}`);
|
||||
for (const body of boundAgents) {
|
||||
assert.equal(carriesBoundMethodMarker(instructionSource(body, mastra)), true, body.slice(0, 160));
|
||||
}
|
||||
assert.match(jyotishSkillMethodCoreBlock, new RegExp(BOUND_METHOD_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.doesNotMatch(jyotishSkillMethodCoreBlock, /Level 2 report template/);
|
||||
assert.match(jyotishSkillMethodBlock, /Level 2 report template/);
|
||||
});
|
||||
|
||||
test("window agent input processing does not abort (BUG-954)", async () => {
|
||||
const { getWindowJyotishAgent } = await import("../src/mastra/index.ts");
|
||||
const { createConsultationRuntimeState, createWindowConsultationAgentContext } = await import("../src/mastra/consultation-tools.ts");
|
||||
const model = {
|
||||
id: "window-binding-probe",
|
||||
label: "Window probe",
|
||||
description: "",
|
||||
creditCost: 1,
|
||||
isDefault: false,
|
||||
mode: "openai",
|
||||
model: "openai/gpt-5-mini",
|
||||
} as never;
|
||||
const agent = getWindowJyotishAgent(model, createWindowConsultationAgentContext({
|
||||
userId: "user",
|
||||
sessionId: "session",
|
||||
requestId: "req",
|
||||
consultationMode: "declared_birth_window",
|
||||
declaredWindow: {
|
||||
name: "探针",
|
||||
toolInput: {
|
||||
year: 1990, month: 1, day: 2, city: "台北", lat: 25.03, lon: 121.56, tz: 8,
|
||||
ayanamsa: "raman", rangeStart: "04:00", rangeEnd: "06:00",
|
||||
},
|
||||
truth: {
|
||||
birthDate: "1990-01-02",
|
||||
birthTimeSource: "family_period",
|
||||
birthTimePeriod: "morning",
|
||||
birthTimeStatus: "window",
|
||||
wrapsMidnight: false,
|
||||
placeLabel: "台北",
|
||||
placeCodes: { countryCode: "TW", provinceCode: null, cityCode: null, districtCode: null },
|
||||
placeId: null,
|
||||
placeType: "city",
|
||||
placeProvider: "profile",
|
||||
timezoneId: "Asia/Taipei",
|
||||
timezoneSource: "profile",
|
||||
latitude: 25.03,
|
||||
longitude: 121.56,
|
||||
timezoneOffset: 8,
|
||||
},
|
||||
},
|
||||
state: createConsultationRuntimeState(),
|
||||
}));
|
||||
const instructions = await agent.getInstructions();
|
||||
const text = typeof instructions === "string" ? instructions : JSON.stringify(instructions);
|
||||
assert.match(text, /<jyotish-skill name="/);
|
||||
assert.doesNotMatch(text, /Level 2 report template/);
|
||||
const abortReasons: string[] = [];
|
||||
assert.doesNotThrow(() => jyotishSkillBoundProcessor.processInputStep({
|
||||
messageList: { getAllSystemMessages: () => [{ role: "system", content: text }] },
|
||||
abort: ((reason?: string) => {
|
||||
abortReasons.push(reason ?? "");
|
||||
throw new Error("aborted");
|
||||
}) as never,
|
||||
} as never));
|
||||
assert.equal(abortReasons.length, 0);
|
||||
});
|
||||
|
||||
test("only chart-answering agents bind the method", () => {
|
||||
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
|
||||
const chart = mastra.slice(
|
||||
|
||||
Reference in New Issue
Block a user