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>
187 lines
8.2 KiB
TypeScript
187 lines
8.2 KiB
TypeScript
/**
|
||
* skill/example 工具的**誠實性**測試(2026-08-13,Arcrun#100/#109 同一族)。
|
||
*
|
||
* 真實事故:`arcrun_get_skill('write_intent_workflow')` 回「skill ... **不存在**」,
|
||
* 但同一台實例的 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
|
||
*(`entry_type: agent-skill`,`source: installer-seed`)——**查的鍵逐字相符**。
|
||
* 真兇是 `if (!resp.ok) return null;`:一個 HTTP 401 被翻譯成「不存在」。
|
||
*
|
||
* 這個謊有實害:instructions 叫 AI 第一步先讀 skill,它照做、收到「不存在」,
|
||
* 於是結論「這裡沒有 skill」⇒ 去猜/grep repo/上網。
|
||
*
|
||
* 判準:**讀不到(401/403/5xx/連不上)與不存在(KBDB 正常回應但沒這張卡)必須長得不一樣**,
|
||
* 且讀不到時要交出還走得通的下一步(`kbdb_search`)。
|
||
*/
|
||
import { describe, it, expect } from "vitest";
|
||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import type { Env } from "../../../src/types.js";
|
||
import {
|
||
registerGetSkill,
|
||
registerListSkills,
|
||
registerGetExample,
|
||
} from "../../../src/tools/arcrun_skills_examples.js";
|
||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||
|
||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||
const PORTAL: KnowledgeIdentity = {
|
||
kind: "portal",
|
||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||
};
|
||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||
|
||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||
content: { type: string; text: string }[];
|
||
isError?: boolean;
|
||
}>;
|
||
|
||
function fakeServer() {
|
||
const tools = new Map<string, ToolHandler>();
|
||
const server = {
|
||
tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => {
|
||
tools.set(name, handler);
|
||
},
|
||
} as unknown as McpServer;
|
||
return { server, tools };
|
||
}
|
||
|
||
function makeEnv(respond: (url: string) => Response): Env {
|
||
return {
|
||
KBDB_INTERNAL_TOKEN: "",
|
||
KBDB: { fetch: async (url: string) => respond(url) },
|
||
} as unknown as Env;
|
||
}
|
||
|
||
const parse = (res: { content: { text: string }[] }) => JSON.parse(res.content[0].text);
|
||
|
||
describe("arcrun_get_skill — 401 不准講成「不存在」", () => {
|
||
it("KBDB 回 401 → kbdb_unauthorized,訊息明說「讀不到不是不存在」並給 kbdb_search 這條路", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), SERVICE);
|
||
const res = await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" });
|
||
|
||
expect(res.isError).toBe(true);
|
||
const body = parse(res);
|
||
expect(body.error_code).toBe("kbdb_unauthorized");
|
||
// 🔴 這句是本次事故的核心:不可以再出現舊版那句「skill "x" 不存在」的結論
|
||
expect(body.human_message).not.toMatch(/skill "[^"]*" 不存在/);
|
||
expect(body.human_message).toContain("這是「讀不到」,不是「不存在」");
|
||
expect(body.human_message).toContain("讀不到");
|
||
expect(body.human_message).toContain("HTTP 401");
|
||
// 還走得通的那條路要交到 AI 手上(實測 kbdb_search 撈得到同一張卡)
|
||
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'skill-write_intent_workflow' })");
|
||
// 並明白禁止它改口
|
||
expect(body.next_actions.join("\n")).toContain("不准把這個錯誤回報成");
|
||
});
|
||
|
||
it("以帳密登入的連線收到 401 → additionally 說清楚「不是你的帳號讀不到」", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), PORTAL);
|
||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_recipe" }));
|
||
expect(body.human_message).toContain("服務內部憑據");
|
||
expect(body.human_message).toContain("不代表你的帳號讀不到");
|
||
});
|
||
|
||
it("KBDB 5xx → kbdb_unreachable(連不上,也不是不存在)", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerGetSkill(server, makeEnv(() => new Response("boom", { status: 503 })), SERVICE);
|
||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "whatever" }));
|
||
expect(body.error_code).toBe("kbdb_unreachable");
|
||
expect(body.human_message).toContain("HTTP 503");
|
||
});
|
||
|
||
it("binding 直接爆炸 → 也回 kbdb_unreachable,不吞成 not_found", async () => {
|
||
const { server, tools } = fakeServer();
|
||
const env = {
|
||
KBDB: {
|
||
fetch: async () => {
|
||
throw new Error("kbdb down");
|
||
},
|
||
},
|
||
} as unknown as Env;
|
||
registerGetSkill(server, env, SERVICE);
|
||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
|
||
expect(body.error_code).toBe("kbdb_unreachable");
|
||
expect(body.human_message).toContain("kbdb down");
|
||
});
|
||
|
||
it("KBDB 正常回應但真的沒這張卡 → not_found,且說明是「這台實例沒 seed」不是全世界沒有", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerGetSkill(
|
||
server,
|
||
makeEnv(() => new Response(JSON.stringify({ entries: [] }))),
|
||
SERVICE,
|
||
);
|
||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "INDEX" }));
|
||
expect(body.error_code).toBe("not_found");
|
||
expect(body.human_message).toContain("KBDB 正常回應");
|
||
expect(body.human_message).toContain("skill-INDEX");
|
||
expect(body.next_actions.join("\n")).toContain("arcrun_list_skills()");
|
||
});
|
||
|
||
it("卡片真的在 → 照舊回內容(沒改壞正路)", async () => {
|
||
const { server, tools } = fakeServer();
|
||
let seen = "";
|
||
registerGetSkill(
|
||
server,
|
||
makeEnv((url) => {
|
||
seen = url;
|
||
return new Response(
|
||
JSON.stringify({
|
||
entries: [{ id: "e1", page_name: "skill-write_intent_workflow", content: "# 意圖工作流", tags_json: '["skill:core"]' }],
|
||
}),
|
||
);
|
||
}),
|
||
SERVICE,
|
||
);
|
||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" }));
|
||
expect(body.ok).toBe(true);
|
||
expect(body.data.content).toBe("# 意圖工作流");
|
||
expect(seen).toContain("page_name=skill-write_intent_workflow");
|
||
});
|
||
|
||
it("舊 token(stale)→ identity_missing(誠實要求重新連線,不謊稱找不到)", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerGetSkill(server, makeEnv(() => new Response("{}")), STALE);
|
||
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
|
||
expect(body.error_code).toBe("identity_missing");
|
||
});
|
||
});
|
||
|
||
describe("arcrun_list_skills / arcrun_get_example — 同一條規則", () => {
|
||
it("list_skills 撞 401 → kbdb_unauthorized(舊版是 fetch_failed 一句技術話)", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerListSkills(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
|
||
const body = parse(await tools.get("arcrun_list_skills")!({}));
|
||
expect(body.error_code).toBe("kbdb_unauthorized");
|
||
expect(body.next_actions.join("\n")).toContain("kbdb_search");
|
||
});
|
||
|
||
it("list_skills 成功時提醒「只用清單裡真的有的 slug」(別再猜 INDEX)", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerListSkills(
|
||
server,
|
||
makeEnv(() =>
|
||
new Response(
|
||
JSON.stringify({
|
||
entries: [{ id: "e1", page_name: "skill-write_recipe", content: "x", tags_json: "[]" }],
|
||
}),
|
||
),
|
||
),
|
||
SERVICE,
|
||
);
|
||
const body = parse(await tools.get("arcrun_list_skills")!({}));
|
||
expect(body.ok).toBe(true);
|
||
expect(body.data.count).toBe(1);
|
||
expect(body.hints.join("\n")).toContain("不要硬猜名字");
|
||
});
|
||
|
||
it("get_example 撞 401 → 同樣是讀不到,不是「example 不存在」", async () => {
|
||
const { server, tools } = fakeServer();
|
||
registerGetExample(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
|
||
const body = parse(await tools.get("arcrun_get_example")!({ slug: "rag-search-answer" }));
|
||
expect(body.error_code).toBe("kbdb_unauthorized");
|
||
expect(body.human_message).not.toMatch(/example "[^"]*" 不存在/);
|
||
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'example-rag-search-answer' })");
|
||
});
|
||
});
|