From e023af5be3ccae23058b97c62f3114bd17cda07f Mon Sep 17 00:00:00 2001 From: "zeyu.fz" Date: Mon, 24 Aug 2026 19:34:15 +0800 Subject: [PATCH] =?UTF-8?q?docs(kb-dsh):=20=E4=BC=98=E5=8C=96=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E6=9C=8D=E5=8A=A1=E6=B8=85=E5=8D=95=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补查型工具(service_find)确认不暴露服务清单,避免与catalog冲突 - 清单内容策略扩展,0服务时注入明确禁止猜测id的提示 - 工具描述保持静态,上下文消息注入带source的UserMessage实现动态清单 - bl命令及安装提示仅在动态文本中出现,避免静态描述频繁消耗token - 错误处理中4xx刷新并追加服务清单,0服务状态下明确提示不重试须创建部署 - service-catalog新增无服务提示及刷新服务列表构建函数 - service-context调整使用新清单构建逻辑,缓存空时注入无服务通知 - tools调整描述文案,提示来自上下文消息且拒绝猜测 - README补充bl CLI安装使用说明 - 测试补充无服务情况注入提示及刷新列表文本内容校验 --- docs/kb-dsh/runtime-behavior.md | 30 +++-- packages/bailian-kb-dsh/README.md | 1 + packages/bailian-kb-dsh/README.zh.md | 1 + packages/bailian-kb-dsh/src/index.ts | 25 +--- .../bailian-kb-dsh/src/service-catalog.ts | 115 ++++++++++++++++-- .../bailian-kb-dsh/src/service-context.ts | 24 ++-- packages/bailian-kb-dsh/src/tools.ts | 17 ++- .../tests/service-catalog.test.ts | 77 +++++++++++- .../tests/service-context.test.ts | 35 ++++-- 9 files changed, 262 insertions(+), 63 deletions(-) diff --git a/docs/kb-dsh/runtime-behavior.md b/docs/kb-dsh/runtime-behavior.md index a59020f..7e219c8 100644 --- a/docs/kb-dsh/runtime-behavior.md +++ b/docs/kb-dsh/runtime-behavior.md @@ -67,6 +67,8 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两 模型要判断"该不该检索",靠的是看到本 workspace 部署了哪些检索服务。插件内部经 `/api/v1/indices/rag/app/list` 拉取该清单并缓存,**不对模型暴露服务发现工具**(`kb_service_list` 不会回归:它会把"先 list 再 search"的额外一轮重新引入);管理面仍用 bl。 +补查型工具("只按关键词查、不列全部"的 `service_find`)也评估过,同样不做。理由不是成本而是**收益已被占掉**:唯一能支撑它的论据是"兜底走 bash + bl 不一定可用",而管理面本来就以 bl 为前提;catalog 那条通道是零轮次、无条件注入、且带截断告知与默认收敛策略,再开第三个入口只会跟它抢事实源。真正要补的是兜底路径本身——见下面两小节。 + ### 载体:上下文消息,不是工具描述 两个工具的 **description 保持静态**(不含任何服务 id)。清单经 `agent/pre-step` 注入为一条带 source 的 `UserMessage`(`{ kind: 'plugin', plugin: 'tool-bailian-kb/services', form: 'catalog' }`),而不是烘进 tool description。两个原因: @@ -78,15 +80,29 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两 ### 清单内容策略 -| 情形 | 注入内容 | -| ------------------------ | ----------------------------------------------------- | -| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 | -| 未配默认,deployed ≤ 10 | 全量 `agent_id` + 名称 | -| 未配默认,deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 | -| 0 个 / 拉取失败 / 无缓存 | 不注入(工具仍可用) | +| 情形 | 注入内容 | +| ------------------------ | ------------------------------------------------------------------------------------ | +| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 | +| 未配默认,deployed ≤ 10 | 全量 `agent_id` + 名称 | +| 未配默认,deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 | +| 缓存里 0 个服务 | 注入 `buildNoServiceNotice()`:禁止编 id,并给出建服务/部署服务的路径(控制台或 bl) | +| 无缓存文档(含拉取失败) | 不注入(工具仍可用) | 英文框架 + 服务名原样保留;空 scene 整节省略;截断必须告知(静默截断会让模型把清单当全集,进而断言"没有对应知识库")。 +最后两行的差别是**能不能下断言**:缓存里有文档但 0 条,是权威的"这个 workspace 没有可调服务",说出来比沉默好——`agent_id` 恒必填,沉默只会让模型编一个 id(换来服务端裸报错)或静默放弃检索,两者在用户看来都像插件坏了。没有文档则意味着首拉还没落地或一直失败,此时任何断言都是猜,交给后台刷新在后续 step 自愈。 + +注意这与"空 scene 整节省略"不冲突:空的**节**是噪音(另一节仍在给 id),空的**清单**是模型手里一个 id 都没有。 + +### bl 引导只出现在动态载体上 + +工具描述是静态的,每次请求都付;而"怎么进一步查"取决于当下部署了什么。所以 `agent_id` 的参数描述**只指向上下文清单**,不写 `bl` 命令;`bl` 出现在两处动态文本里,且**每次出现都带安装方式**(`npm install -g bailian-cli`): + +- catalog 中真正推荐了命令的分支(截断、收敛到默认服务); +- 4xx 之后追加的服务清单(`buildRefreshedSceneList`)。 + +带安装行是必需的:插件走 API 直连、从不 shell out,所以一个凭据齐全的部署完全可能没装 `bl`。而安装引导原本只写在 `skills/bailian-kb/SKILL.md` 的前置检查里,那是二阶决策——要模型先决定加载 skill 才读到,恰好漏掉走工具描述兜底的那个模型。 + ### 缓存与刷新 落点:`${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-.json`(临时文件 + `rename()` 原子发布,目录 `0o700`)。按 workspace 分文件是必需的:api key 只能访问自己的 workspace,而"自动获取"按钮就是为了切账号。 @@ -108,7 +124,7 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两 ## 错误语义 -- HTTP 错误:4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效);5xx 与刷新本身失败则原错误透传; +- HTTP 错误:4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效);刷新后该 scene **一个服务都没有**时也照样追加说明(明确"别换 id 重试"+ 建服务路径),而不是放裸错误过去——裸的 `invalid agent_id` 读起来就是"再试一个";5xx 与刷新本身失败则原错误透传; - 凭证缺失:指向 `~/.dsh/.env` / `.credentials.yaml` 配置方式与控制台取 key 页面; - chat 超时:说明服务端多轮检索特性,建议重试或改用 `kb_search`; - 服务端错误体截断至 500 字符进入错误信息(优先 `code: message`)。 diff --git a/packages/bailian-kb-dsh/README.md b/packages/bailian-kb-dsh/README.md index 9f93271..8d50b34 100644 --- a/packages/bailian-kb-dsh/README.md +++ b/packages/bailian-kb-dsh/README.md @@ -32,6 +32,7 @@ Retrieval happens through **retrieval services** you deploy on Bailian: a servic - DeepSeek Harness with its plugin runtime (`@deepseek-ai/dsh-*`), Node.js >= 22.12 - An Aliyun Model Studio account: a **workspace id** and a **DashScope API key** ([get one](https://bailian.console.aliyun.com/?tab=app#/api-key)) - At least one **deployed** retrieval or Q&A service in that workspace — create one in the [console](https://bailian.console.aliyun.com/) or with `bl knowledge service create` / `bl knowledge service deploy` +- The [`bl` CLI](https://www.npmjs.com/package/bailian-cli) (`npm install -g bailian-cli`) for anything on the management side — creating knowledge bases, ingesting documents, deploying services. Retrieval itself calls the API directly and never shells out, so `kb_search` / `kb_chat` work without it ## Installation diff --git a/packages/bailian-kb-dsh/README.zh.md b/packages/bailian-kb-dsh/README.zh.md index 9ab976b..3a1d266 100644 --- a/packages/bailian-kb-dsh/README.zh.md +++ b/packages/bailian-kb-dsh/README.zh.md @@ -32,6 +32,7 @@ - DeepSeek Harness 及其插件运行时(`@deepseek-ai/dsh-*`),Node.js >= 22.12 - 阿里云百炼账号:一个**工作空间 ID** 和一个 **DashScope API 密钥**([去获取](https://bailian.console.aliyun.com/?tab=app#/api-key)) - 该工作空间下至少有一个**已部署**的检索或问答服务 —— 可在[控制台](https://bailian.console.aliyun.com/)创建,或用 `bl knowledge service create` / `bl knowledge service deploy` +- 管理面的事(建库、导入文档、部署服务)需要 [`bl` CLI](https://www.npmjs.com/package/bailian-cli)(`npm install -g bailian-cli`)。检索本身直连 API、从不起子进程,所以 `kb_search` / `kb_chat` 不装也能用 ## 安装 diff --git a/packages/bailian-kb-dsh/src/index.ts b/packages/bailian-kb-dsh/src/index.ts index c7f3508..bf25e49 100644 --- a/packages/bailian-kb-dsh/src/index.ts +++ b/packages/bailian-kb-dsh/src/index.ts @@ -19,7 +19,7 @@ import { consoleLoginState, startConsoleLogin } from "./console-login.js"; import { KbClient } from "./client.js"; import { registerSkill } from "./skill.js"; import { ServiceCache } from "./service-cache.js"; -import { CATALOG_ENTRY_LIMIT } from "./service-catalog.js"; +import { buildRefreshedSceneList } from "./service-catalog.js"; import { installServiceContext } from "./service-context.js"; import type { ServiceScene } from "./api-types.js"; import { createKbTools } from "./tools.js"; @@ -333,28 +333,15 @@ export function apply(ctx: Context, config: Config): void { resolveDefaultRetrieveAgentId: async () => await resolveDefaultAgentId("search"), resolveDefaultChatAgentId: async () => await resolveDefaultAgentId("chat"), // Self-heal for a cached id the server has since rejected: refresh once and - // put the current list in the error, which reaches the model this step. + // put the current list in the error, which reaches the model this step. An + // empty result is reported too rather than dropped — a bare "invalid + // agent_id" reads as "try another one", and when nothing is deployed no id + // can work. describeServicesAfterRefresh: async (scene) => { await serviceCache.refresh(); const workspaceId = await resolveWorkspaceIdOrUndefined(); if (workspaceId === undefined) return undefined; - const forScene = - serviceCache.peek(workspaceId)?.entries.filter((entry) => entry.scene === scene) ?? []; - if (forScene.length === 0) return undefined; - const lines = forScene - .slice(0, CATALOG_ENTRY_LIMIT) - .map( - (entry) => - `- ${entry.agent_id} — ${entry.agent_name === "" ? "(unnamed)" : entry.agent_name}`, - ); - const more = forScene.length - lines.length; - return [ - `Deployed ${scene} services in this workspace, re-read just now:`, - ...lines, - ...(more > 0 - ? [`(and ${more} more — \`bl knowledge service list --scene ${scene}\`)`] - : []), - ].join("\n"); + return buildRefreshedSceneList(scene, serviceCache.entriesFor(workspaceId, scene)); }, get chatTimeoutMs() { return current().chatTimeoutMs; diff --git a/packages/bailian-kb-dsh/src/service-catalog.ts b/packages/bailian-kb-dsh/src/service-catalog.ts index 3605ad3..f4deb37 100644 --- a/packages/bailian-kb-dsh/src/service-catalog.ts +++ b/packages/bailian-kb-dsh/src/service-catalog.ts @@ -1,8 +1,8 @@ /** * Renders the deployed-service catalog the model reads before deciding whether - * to retrieve. + * to retrieve, plus the notice for a workspace that deploys nothing at all. * - * This is a pure function on purpose: the four selection branches below are the + * These are pure functions on purpose: the selection branches below are the * whole routing policy, and they are far easier to pin down in tests than * through a live pre-step. * @@ -12,7 +12,11 @@ * - Truncation is always stated. Silently cutting the list makes the model treat * it as complete and flatly answer "there is no such knowledge base". * - An empty scene omits its whole section. `no chat services` is pure noise and - * invites the model to handle a case that does not exist. + * invites the model to handle a case that does not exist. An empty CATALOG is + * the opposite case and does get a message: see {@link buildNoServiceNotice}. + * - `bl` is only ever recommended together with how to get it. This plugin talks + * to the API directly and never shells out, so a fully configured deployment + * can have no `bl` on PATH at all. */ import type { ServiceScene } from "./api-types.js"; @@ -24,6 +28,17 @@ export const CATALOG_ENTRY_LIMIT = 10; /** Truncation applied to a service description once the backend returns one. */ const DESCRIPTION_LIMIT = 200; +/** + * Appended once whenever the text tells the model to look further with `bl`. + * + * The install line belongs next to the recommendation, not in the tool + * descriptions: those are static and every token there is spent on every + * request, while this is needed only in the branches that actually name the + * command. A single-service catalog never carries it. + */ +const BL_AVAILABILITY_NOTE = + "(`bl` is the Bailian CLI — install it with `npm install -g bailian-cli` if the command is not found.)"; + export interface CatalogInput { entries: readonly ServiceEntry[]; /** Server-reported total, which may exceed `entries` when the fetch itself was capped. */ @@ -74,7 +89,7 @@ function renderScene( scene: ServiceScene, defaultAgentId: string | undefined, truncatedFetch: boolean, -): string[] | undefined { +): { lines: string[]; usedLookupHint: boolean } | undefined { const forScene = entries.filter((entry) => entry.scene === scene); if (forScene.length === 0) return undefined; const lines = [`${SCENE_LABEL[scene]}:`]; @@ -93,13 +108,14 @@ function renderScene( ` (default service; ${others} other${others === 1 ? "" : "s"} exist — ` + `run \`bl knowledge service list --scene ${scene}\` to see them)`, ); + return { lines, usedLookupHint: true }; } - return lines; + return { lines, usedLookupHint: false }; } if (forScene.length <= CATALOG_ENTRY_LIMIT && !truncatedFetch) { lines.push(...forScene.map(renderEntry)); - return lines; + return { lines, usedLookupHint: false }; } const shown = [...forScene].sort(byRecency).slice(0, CATALOG_ENTRY_LIMIT); @@ -112,7 +128,86 @@ function renderScene( `${knownTotal} deployed ${scene} services — run \`bl knowledge service list --scene ${scene} ` + "--name ` to look for others)", ); - return lines; + return { lines, usedLookupHint: true }; +} + +/** + * The notice injected when the workspace is reachable but deploys nothing the + * tools can call. + * + * This deliberately contradicts the "omit what does not exist" rule above, and + * the cases are not symmetric: an empty SECTION is noise because the other + * section still hands the model ids, whereas an empty CATALOG leaves it with no + * id at all while `agent_id` stays required. With nothing said, the model either + * invents an id — earning a raw server rejection — or silently drops the + * retrieval; both read to the user as a broken plugin when the real answer is + * "deploy a service". Naming the fix is the only way out that does not require + * the model to already have loaded the management skill. + * @returns the model-facing notice text. + */ +export function buildNoServiceNotice(): string { + return [ + "", + "The Bailian knowledge tools (kb_search / kb_chat) are configured, but this workspace has no " + + "deployed knowledge service, so neither tool can be called: both require an `agent_id` and there " + + "is none to use. Do not call them, and do not invent an id.", + "", + "If the user asks for something that should come from their knowledge base, tell them a service has " + + "to be created and deployed first — in the Bailian console (https://bailian.console.aliyun.com/), " + + "or with `bl knowledge service create` followed by `bl knowledge service deploy`. " + + BL_AVAILABILITY_NOTE, + "", + ].join("\n"); +} + +/** + * The service list appended to a call the server rejected, rendered from a + * just-refreshed cache. + * + * Lives here rather than at the call site so every model-facing rendering obeys + * the one set of conventions documented at the top of this file — in particular + * that a shortfall is stated and that `bl` never appears without its install + * line. + * @param scene - the scene the failed call targeted. + * @param entries - that scene's entries as of the refresh, newest first. + * @returns the text to append to the failure. + */ +export function buildRefreshedSceneList( + scene: ServiceScene, + entries: readonly ServiceEntry[], +): string { + if (entries.length === 0) return buildNoSceneServiceNotice(scene); + const shown = entries.slice(0, CATALOG_ENTRY_LIMIT); + const more = entries.length - shown.length; + return [ + `Deployed ${scene} services in this workspace, re-read just now:`, + ...shown.map(renderEntry), + ...(more > 0 + ? [ + `(and ${more} more — run \`bl knowledge service list --scene ${scene}\` to see them) ` + + BL_AVAILABILITY_NOTE, + ] + : []), + ].join("\n"); +} + +/** + * What a rejected call is told when the refreshed list holds no service for that + * scene at all. + * + * The tool has already failed by this point, so this text is the model's only + * instruction. Without it the bare server rejection ("invalid agent_id") invites + * a retry with another guess, and no guess can succeed. + * @param scene - the scene whose services came back empty. + * @returns the text to append to the failure. + */ +function buildNoSceneServiceNotice(scene: ServiceScene): string { + return ( + `This workspace has no deployed ${scene} services at all (re-checked just now), so no agent_id can ` + + "work — do not retry with a different id. Tell the user one has to be created and deployed, in the " + + "Bailian console (https://bailian.console.aliyun.com/) or with `bl knowledge service create` " + + `followed by \`bl knowledge service deploy\`. ${BL_AVAILABILITY_NOTE}` + ); } /** @@ -129,6 +224,7 @@ export function buildServiceCatalog(input: CatalogInput): string | undefined { ); const chat = renderScene(input.entries, "chat", input.defaultChatAgentId, input.truncated); if (search === undefined && chat === undefined) return undefined; + const usedLookupHint = search?.usedLookupHint === true || chat?.usedLookupHint === true; return [ "", // The header must not name the tools: a scene with no services omits its @@ -138,12 +234,13 @@ export function buildServiceCatalog(input: CatalogInput): string | undefined { "Pass an id from the matching section as that tool's `agent_id` argument — it is required and " + "cannot be guessed.", "", - ...(search ?? []), + ...(search?.lines ?? []), ...(search !== undefined && chat !== undefined ? [""] : []), - ...(chat ?? []), + ...(chat?.lines ?? []), "", "If none of these services covers what the user is asking about, say so plainly rather than " + "trying the closest-looking id — an unrelated retrieval result is worse than none.", + ...(usedLookupHint ? [BL_AVAILABILITY_NOTE] : []), "", ].join("\n"); } diff --git a/packages/bailian-kb-dsh/src/service-context.ts b/packages/bailian-kb-dsh/src/service-context.ts index 72cb27e..b395c3d 100644 --- a/packages/bailian-kb-dsh/src/service-context.ts +++ b/packages/bailian-kb-dsh/src/service-context.ts @@ -25,7 +25,7 @@ import type { Context } from "@deepseek-ai/cordis"; import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent"; import { createUserMessage } from "@deepseek-ai/dsh-llm"; import type { UserMessage } from "@deepseek-ai/dsh-session"; -import { buildServiceCatalog } from "./service-catalog.js"; +import { buildNoServiceNotice, buildServiceCatalog } from "./service-catalog.js"; import type { ServiceCache } from "./service-cache.js"; /** Marks this plugin's own injections in the durable log. */ @@ -102,20 +102,26 @@ export function installServiceContext(ctx: Context, opts: ServiceContextOptions) if (opts.cache.isStale(workspaceId)) void opts.cache.refresh(); const document = opts.cache.peek(workspaceId); + // No document at all means the first fetch has not landed (or is + // failing): we know nothing, so claiming anything would be a guess. The + // refresh above self-corrects this on a later step. if (document === undefined) return decision; const [defaultRetrieveAgentId, defaultChatAgentId] = await Promise.all([ opts.resolveDefaultRetrieveAgentId(), opts.resolveDefaultChatAgentId(), ]); if (signal.aborted) return decision; - const text = buildServiceCatalog({ - entries: document.entries, - total: document.total, - truncated: document.truncated, - ...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}), - ...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}), - }); - if (text === undefined) return decision; + // A document with zero entries is the opposite: an authoritative "this + // workspace deploys nothing callable". Saying so beats silence, which + // leaves the model to invent a required agent_id. + const text = + buildServiceCatalog({ + entries: document.entries, + total: document.total, + truncated: document.truncated, + ...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}), + ...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}), + }) ?? buildNoServiceNotice(); // Identical to what the model already sees: stay out of the way. This is // the branch that runs on nearly every step. diff --git a/packages/bailian-kb-dsh/src/tools.ts b/packages/bailian-kb-dsh/src/tools.ts index d71ca4b..32075e5 100644 --- a/packages/bailian-kb-dsh/src/tools.ts +++ b/packages/bailian-kb-dsh/src/tools.ts @@ -12,6 +12,12 @@ * changed schema token. The live catalog therefore rides an `agent/pre-step` * context message instead (see `service-context.ts`), leaving these schemas * byte-stable for the life of the process. + * + * Being static is also why recovery instructions do NOT live here: how to look + * further depends on what is actually deployed, and a token spent in a + * description is spent on every request. The dynamic carriers own that — the + * catalog message states a shortfall where one exists, and a rejected call comes + * back with the services that do exist (`service-catalog.ts`). */ import { defineTool } from "@deepseek-ai/dsh-tools"; @@ -83,12 +89,11 @@ export function createKbTools(deps: KbToolDeps) { required: true as const, description: "Retrieval/Q&A service id. REQUIRED: the schema cannot know whether this deployment " + - "configures a default service, so always pass one. The deployed services of this workspace, " + - "with their ids, are listed in a context message in this conversation; take the id from the " + - "section matching the tool you are calling. If that list is absent or none of its services " + - "covers the question, run `bl knowledge service list --scene search --name ` to look " + - "(workspaceId resolves automatically from DSH settings: bailian-kb.workspaceId in " + - "~/.dsh/settings.yaml).", + "configures a default service, so always pass one. This conversation carries a context message " + + "listing the services deployed in the workspace — take the id from the section matching the tool " + + "you are calling. When that list is absent, or none of its services covers the question, say so " + + "rather than guessing: a rejected id is answered with the services that do exist, so a guess buys " + + "nothing.", }; const resolveRetrieveAgentId = async (supplied: string | undefined): Promise => { if (supplied !== undefined) return supplied; diff --git a/packages/bailian-kb-dsh/tests/service-catalog.test.ts b/packages/bailian-kb-dsh/tests/service-catalog.test.ts index 30bf7d1..7890ab7 100644 --- a/packages/bailian-kb-dsh/tests/service-catalog.test.ts +++ b/packages/bailian-kb-dsh/tests/service-catalog.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { CATALOG_ENTRY_LIMIT, buildServiceCatalog } from "../src/service-catalog.js"; +import { + CATALOG_ENTRY_LIMIT, + buildNoServiceNotice, + buildRefreshedSceneList, + buildServiceCatalog, +} from "../src/service-catalog.js"; import type { ServiceEntry } from "../src/services.js"; function entry(overrides: Partial & { agent_id: string }): ServiceEntry { @@ -19,8 +24,9 @@ function catalog( } describe("buildServiceCatalog", () => { - it("returns undefined when there is nothing worth injecting", () => { - // No services at all: the tool descriptions alone keep the tools usable. + it("returns undefined when there is no service to render", () => { + // Nothing to route to; the caller substitutes buildNoServiceNotice(), which + // is the branch that tells the model what to do about it. expect(catalog([])).toBeUndefined(); }); @@ -119,4 +125,69 @@ describe("buildServiceCatalog", () => { expect(text).not.toContain("x".repeat(201)); expect(text).toContain("…"); }); + + it("explains how to get bl only where it recommends running it", () => { + // The plugin never shells out, so a fully configured deployment can have no + // bl on PATH. Naming the command without the install line is a dead end. + const complete = catalog([entry({ agent_id: "aid-1" })]) ?? ""; + expect(complete).not.toContain("bl knowledge service list"); + expect(complete).not.toContain("npm install -g bailian-cli"); + + const capped = + catalog(Array.from({ length: 12 }, (_x, index) => entry({ agent_id: `aid-${index}` }))) ?? ""; + expect(capped).toContain("bl knowledge service list"); + expect(capped).toContain("npm install -g bailian-cli"); + + // Same rule on the collapsed-to-default branch, which also names the command. + const collapsed = + catalog([entry({ agent_id: "aid-1" }), entry({ agent_id: "aid-2" })], { + defaultRetrieveAgentId: "aid-1", + }) ?? ""; + expect(collapsed).toContain("bl knowledge service list"); + expect(collapsed).toContain("npm install -g bailian-cli"); + }); +}); + +describe("buildNoServiceNotice", () => { + it("names the way out instead of leaving the model to invent an agent_id", () => { + const text = buildNoServiceNotice(); + // agent_id is required, so silence here means a guessed id or a silently + // dropped retrieval — both look like a broken plugin to the user. + expect(text).toMatch(/do not invent an id/i); + expect(text).toContain("bl knowledge service create"); + expect(text).toContain("bl knowledge service deploy"); + // The management skill is a second-order path: a model that never loads it + // must still learn how to get bl. + expect(text).toContain("npm install -g bailian-cli"); + expect(text).toContain("https://bailian.console.aliyun.com/"); + }); +}); + +describe("buildRefreshedSceneList", () => { + it("lists the scene's services for a rejected call", () => { + const text = buildRefreshedSceneList("search", [ + entry({ agent_id: "aid-1", agent_name: "产品文档" }), + ]); + expect(text).toContain("aid-1 — 产品文档"); + expect(text).toMatch(/re-read just now/i); + expect(text).not.toContain("npm install -g bailian-cli"); + }); + + it("states the shortfall and how to get bl when the list is capped", () => { + const entries = Array.from({ length: CATALOG_ENTRY_LIMIT + 3 }, (_x, index) => + entry({ agent_id: `aid-${index}` }), + ); + const text = buildRefreshedSceneList("search", entries); + expect(text).toContain("and 3 more"); + expect(text).toContain("npm install -g bailian-cli"); + }); + + it("tells a rejected call to stop retrying when the scene has no services", () => { + // Returning nothing here used to let the bare "invalid agent_id" through, + // which reads as an invitation to try a different id. + const text = buildRefreshedSceneList("chat", []); + expect(text).toMatch(/no deployed chat services/i); + expect(text).toMatch(/do not retry with a different id/i); + expect(text).toContain("bl knowledge service deploy"); + }); }); diff --git a/packages/bailian-kb-dsh/tests/service-context.test.ts b/packages/bailian-kb-dsh/tests/service-context.test.ts index 6a8050f..c3c8d81 100644 --- a/packages/bailian-kb-dsh/tests/service-context.test.ts +++ b/packages/bailian-kb-dsh/tests/service-context.test.ts @@ -17,6 +17,8 @@ interface HarnessOptions { entries?: { agent_id: string; agent_name: string; scene: "search" | "chat"; status: string }[]; cacheOverride?: ServiceCache; postJson?: () => Promise; + /** Skip seeding the cache file, i.e. the very first step of a fresh install. */ + withoutCacheFile?: boolean; } /** Drives the installed `agent/pre-step` listener against a fake session. */ @@ -25,15 +27,17 @@ function harness(options: HarnessOptions = {}) { const entries = options.entries ?? [ { agent_id: "aid-1", agent_name: "svc-one", scene: "search" as const, status: "deployed" }, ]; - writeServiceCache(serviceCachePath(WS, home), { - version: 1, - fetchedAt: Date.now(), - workspaceId: WS, - endpointHost: HOST, - entries, - total: entries.length, - truncated: false, - }); + if (options.withoutCacheFile !== true) { + writeServiceCache(serviceCachePath(WS, home), { + version: 1, + fetchedAt: Date.now(), + workspaceId: WS, + endpointHost: HOST, + entries, + total: entries.length, + truncated: false, + }); + } const postJson = vi.fn( options.postJson ?? (async () => ({ code: "Success", data: { total_count: 0, rows: [] } })), ); @@ -145,8 +149,19 @@ describe("installServiceContext", () => { expect(h.injected(second)).toHaveLength(1); }); - it("injects nothing when the cache holds no services", async () => { + it("injects the no-service notice when the cache holds no services", async () => { + // A cached empty list is authoritative, not a missing fetch: staying silent + // would leave the model to invent the required agent_id. const h = harness({ entries: [] }); + const own = h.injected(await h.step()); + expect(own).toHaveLength(1); + expect(own[0]?.content[0]?.text).toMatch(/no\s+deployed knowledge service/i); + }); + + it("injects nothing while no cache document exists yet", async () => { + // Nothing has been fetched, so any claim about the workspace would be a + // guess; the background refresh corrects this on a later step. + const h = harness({ withoutCacheFile: true }); expect(h.injected(await h.step())).toHaveLength(0); });