c2897ba68b
leo 2026-08-13:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,
從你對 Arcrun 的認知就知道你的內建 Memory 是沒用的。」
## 病灶一:指路的話被綁在一個會無聲消失的段落上
instructions 裡唯一提到「這裡有知識可查」的,是 buildLibraryMapInstructions
拼上去的【藏書地圖】——而那段是**選配的**(1500ms 逾時、catch 回 null、失敗無聲)。
必定出現的那段靜態文字(startHere)從頭到尾只講工作流、零件、recipe、邊的語法,
`kbdb_search`/`kbdb_get_map` 一個字都沒有。
實害:同一天,一條 portal 連線的 session 為了回答「Arcrun 是什麼」去讀了 682 行
原始碼——而 `kbdb_search(q="Arcrun 是什麼")` 當下回得出 33 筆真答案。
修法:
- 新增 `KNOWLEDGE_FIRST` 靜態段(與 startHere 同級,KBDB 掛了照樣出現),
排在最前面:這條連線後面有主人的知識庫、第一個動作是 kbdb_search、
工具怎麼呼叫、以及「沒查到/沒查/地圖沒取到」三件事不可以講成同一句。
- 地圖失敗**不再靜默**:拿不到就印【藏書地圖:這次沒取到】,
stale token 另印身分版(要使用者重新連線)——「沒取到」與「這裡沒有知識」
不可以長得一樣(Arcrun#109 同族)。鐵律不變:失敗仍然不擋連線。
- 抽出 `buildServerInstructions(env, identity)` 供測試印出完整字串。
## 病灶二:一個 401 被逐字翻譯成「不存在」
`arcrun_get_skill('write_intent_workflow')` 回「skill ... 不存在」,
但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
(entry_type: agent-skill、source: installer-seed)——**與本工具查的鍵逐字相符**。
真兇是 `kbdbGetByPageName` 的 `if (!resp.ok) return null;`。
而 401 的來源是該實例的 arcrun-mcp 沒有 KBDB_INTERNAL_TOKEN
(kbdb-client 沒 token 就匿名送出)=部署面的事,但這支 code 把它講成了假話。
修法:
- 非 2xx 一律拋 KbdbAccessError(帶真 status)→ kbdb_unauthorized/kbdb_unreachable,
訊息明說「這是讀不到,不是不存在」,並交出還走得通的那條路(kbdb_search)。
- `not_found` 只留給「KBDB 正常回應但真的沒這張卡」,並說明是「這台實例沒 seed」。
- 五支工具吃 identity:stale → identity_missing;portal 撞 401 時額外說明
「是這批工具還走服務憑據,不是你的帳號讀不到」。
- instructions 步驟 4 不再指名 `arcrun_get_skill('INDEX')`(leo21c 實測根本沒 seed
這支)——改成先 `arcrun_list_skills()` 看這台實例真的有哪幾支。
## 驗
- `mcp` 測試 130 passed(新增 mcp-instructions 7 項+skills-examples-honesty 10 項),tsc exit 0。
- 三種情境(地圖抓得到/抓不到/舊 token)各印一份完整 instructions 實測,
三份的開場都指向 kbdb_search。
⚠️ 未部署(部署是 leo 的手動閘)。上線後才會到用戶手上。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.6 KiB
TypeScript
131 lines
5.6 KiB
TypeScript
/**
|
||
* server instructions(`initialize` 時送出的那份,AI **沒辦法自己再要一次**)。
|
||
*
|
||
* 這份測試守的是 2026-08-13 leo 那句話:
|
||
* 「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**。」
|
||
*
|
||
* 病灶(實測):instructions 裡唯一提到「這裡有知識可查」的,是**選配的**【藏書地圖】段
|
||
* (1500ms 逾時、失敗回 null、無聲消失)。一條 portal 連線就沒收到它 ⇒ 那個 session
|
||
* 為了回答「Arcrun 是什麼」去讀了 682 行原始碼,而 `kbdb_search` 當下回得出 33 筆真答案。
|
||
*
|
||
* 所以本檔的驗收判準只有一句:
|
||
* **不管地圖抓不抓得到、身分是哪一種,讀完這份 instructions 的下一個動作都必須是「去查知識庫」。**
|
||
*/
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { buildServerInstructions } from "../../src/mcp-handler.js";
|
||
import { __resetLibraryMapInstructionsCacheForTests } from "../../src/lib/library-map.js";
|
||
import type { Env } from "../../src/types.js";
|
||
import type { KnowledgeIdentity } from "../../src/lib/portal-client.js";
|
||
|
||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||
|
||
/** 假 KBDB service binding(服務級憑據路徑)。 */
|
||
function makeEnv(respond: () => Response | Promise<Response>): Env {
|
||
return { KBDB: { fetch: async () => respond() } } as unknown as Env;
|
||
}
|
||
|
||
const MAP_OK = () =>
|
||
new Response(
|
||
JSON.stringify({
|
||
success: true,
|
||
libraries: [
|
||
{ library: "kb", narrative: "leo 的知識庫主庫", top_entities: ["Arcrun"], triplet_count: 1854 },
|
||
],
|
||
count: 1,
|
||
}),
|
||
);
|
||
|
||
/**
|
||
* 「一個什麼都不知道的 AI 讀完,下一個動作會是什麼」的機械化判準。
|
||
* 三份 instructions 都必須通過同一組斷言——通不過就是還沒修好。
|
||
*/
|
||
function expectPointsAtKnowledge(text: string) {
|
||
// ① 明講這條連線後面有主人的知識庫
|
||
expect(text).toContain("知識庫");
|
||
// ② 指名工具與呼叫法(AI 不必猜工具名)
|
||
expect(text).toContain("kbdb_search");
|
||
expect(text).toContain("Arcrun 是什麼");
|
||
// ③ 明確排除三條錯路
|
||
expect(text).toContain("不是 grep 原始碼");
|
||
expect(text).toContain("不是上網搜");
|
||
expect(text).toContain("不是回答「我不知道」");
|
||
// ④ 「沒查到」與「沒有」不可以混為一談
|
||
expect(text).toContain("那是**讀不到**,不是**不存在**");
|
||
}
|
||
|
||
describe("buildServerInstructions — 三種情境都必須指向知識庫", () => {
|
||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||
|
||
it("① 地圖抓得到:地圖照舊注入,且知識段仍在", async () => {
|
||
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
|
||
expectPointsAtKnowledge(text);
|
||
expect(text).toContain("【藏書地圖】");
|
||
expect(text).toContain("kb:leo 的知識庫主庫");
|
||
// 地圖成功時不該同時出現「沒取到」的話
|
||
expect(text).not.toContain("【藏書地圖:這次沒取到】");
|
||
});
|
||
|
||
it("② 地圖抓不到(HTTP 500):明講「沒取到」,不靜默、也不等於沒有知識", async () => {
|
||
const text = await buildServerInstructions(makeEnv(() => new Response("boom", { status: 500 })), SERVICE);
|
||
expectPointsAtKnowledge(text);
|
||
expect(text).toContain("【藏書地圖:這次沒取到】");
|
||
expect(text).toContain("這是「地圖沒拿到」,不是「這裡沒有知識」");
|
||
});
|
||
|
||
it("② 地圖逾時/binding 爆炸:同樣明講,不擋連線(不 throw)", async () => {
|
||
const env = {
|
||
KBDB: {
|
||
fetch: async () => {
|
||
throw new Error("kbdb down");
|
||
},
|
||
},
|
||
} as unknown as Env;
|
||
const text = await buildServerInstructions(env, SERVICE);
|
||
expectPointsAtKnowledge(text);
|
||
expect(text).toContain("【藏書地圖:這次沒取到】");
|
||
});
|
||
|
||
it("② 地圖回空清單:也算沒取到,不可以長得像「這裡沒有知識」", async () => {
|
||
const text = await buildServerInstructions(
|
||
makeEnv(() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 }))),
|
||
SERVICE,
|
||
);
|
||
expect(text).toContain("【藏書地圖:這次沒取到】");
|
||
expect(text).toContain("不是「這裡沒有知識」");
|
||
});
|
||
|
||
it("③ 舊 token(stale):講成身分問題+給可執行的修法,仍指向知識庫", async () => {
|
||
const text = await buildServerInstructions(makeEnv(MAP_OK), STALE);
|
||
expectPointsAtKnowledge(text);
|
||
expect(text).toContain("舊版簽發的 token");
|
||
expect(text).toContain("identity_missing");
|
||
expect(text).toContain("重新連線");
|
||
// 🔴 不可以講成「知識庫是空的」
|
||
expect(text).toContain("這不代表知識庫是空的");
|
||
});
|
||
|
||
it("stale 不去打 KBDB(地圖本身就是情報,舊 token 不給)", async () => {
|
||
let called = 0;
|
||
const env = {
|
||
KBDB: {
|
||
fetch: async () => {
|
||
called += 1;
|
||
return MAP_OK();
|
||
},
|
||
},
|
||
} as unknown as Env;
|
||
await buildServerInstructions(env, STALE);
|
||
expect(called).toBe(0);
|
||
});
|
||
|
||
it("Arcrun 指路段照舊存在(知識段是新增的,不是取代)", async () => {
|
||
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
|
||
expect(text).toContain("# Arcrun — 你已經配備了這套工具,別上網找");
|
||
expect(text).toContain("【先讀這裡】");
|
||
// 步驟 4 不再指名一個可能沒被 seed 的 slug(leo21c 實測連 INDEX 都沒有)
|
||
expect(text).toContain("arcrun_list_skills()");
|
||
expect(text).not.toContain("arcrun_get_skill('INDEX')");
|
||
});
|
||
});
|