Skip to content

Commit e023af5

Browse files
committed
docs(kb-dsh): 优化知识服务清单注入与错误处理提示
- 补查型工具(service_find)确认不暴露服务清单,避免与catalog冲突 - 清单内容策略扩展,0服务时注入明确禁止猜测id的提示 - 工具描述保持静态,上下文消息注入带source的UserMessage实现动态清单 - bl命令及安装提示仅在动态文本中出现,避免静态描述频繁消耗token - 错误处理中4xx刷新并追加服务清单,0服务状态下明确提示不重试须创建部署 - service-catalog新增无服务提示及刷新服务列表构建函数 - service-context调整使用新清单构建逻辑,缓存空时注入无服务通知 - tools调整描述文案,提示来自上下文消息且拒绝猜测 - README补充bl CLI安装使用说明 - 测试补充无服务情况注入提示及刷新列表文本内容校验
1 parent a28d95a commit e023af5

9 files changed

Lines changed: 262 additions & 63 deletions

File tree

docs/kb-dsh/runtime-behavior.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两
6767

6868
模型要判断"该不该检索",靠的是看到本 workspace 部署了哪些检索服务。插件内部经 `/api/v1/indices/rag/app/list` 拉取该清单并缓存,**不对模型暴露服务发现工具**(`kb_service_list` 不会回归:它会把"先 list 再 search"的额外一轮重新引入);管理面仍用 bl。
6969

70+
补查型工具("只按关键词查、不列全部"的 `service_find`)也评估过,同样不做。理由不是成本而是**收益已被占掉**:唯一能支撑它的论据是"兜底走 bash + bl 不一定可用",而管理面本来就以 bl 为前提;catalog 那条通道是零轮次、无条件注入、且带截断告知与默认收敛策略,再开第三个入口只会跟它抢事实源。真正要补的是兜底路径本身——见下面两小节。
71+
7072
### 载体:上下文消息,不是工具描述
7173

7274
两个工具的 **description 保持静态**(不含任何服务 id)。清单经 `agent/pre-step` 注入为一条带 source 的 `UserMessage`(`{ kind: 'plugin', plugin: 'tool-bailian-kb/services', form: 'catalog' }`),而不是烘进 tool description。两个原因:
@@ -78,15 +80,29 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两
7880

7981
### 清单内容策略
8082

81-
| 情形 | 注入内容 |
82-
| ------------------------ | ----------------------------------------------------- |
83-
| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 |
84-
| 未配默认,deployed ≤ 10 | 全量 `agent_id` + 名称 |
85-
| 未配默认,deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 |
86-
| 0 个 / 拉取失败 / 无缓存 | 不注入(工具仍可用) |
83+
| 情形 | 注入内容 |
84+
| ------------------------ | ------------------------------------------------------------------------------------ |
85+
| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 |
86+
| 未配默认,deployed ≤ 10 | 全量 `agent_id` + 名称 |
87+
| 未配默认,deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 |
88+
| 缓存里 0 个服务 | 注入 `buildNoServiceNotice()`:禁止编 id,并给出建服务/部署服务的路径(控制台或 bl) |
89+
| 无缓存文档(含拉取失败) | 不注入(工具仍可用) |
8790

8891
英文框架 + 服务名原样保留;空 scene 整节省略;截断必须告知(静默截断会让模型把清单当全集,进而断言"没有对应知识库")。
8992

93+
最后两行的差别是**能不能下断言**:缓存里有文档但 0 条,是权威的"这个 workspace 没有可调服务",说出来比沉默好——`agent_id` 恒必填,沉默只会让模型编一个 id(换来服务端裸报错)或静默放弃检索,两者在用户看来都像插件坏了。没有文档则意味着首拉还没落地或一直失败,此时任何断言都是猜,交给后台刷新在后续 step 自愈。
94+
95+
注意这与"空 scene 整节省略"不冲突:空的**节**是噪音(另一节仍在给 id),空的**清单**是模型手里一个 id 都没有。
96+
97+
### bl 引导只出现在动态载体上
98+
99+
工具描述是静态的,每次请求都付;而"怎么进一步查"取决于当下部署了什么。所以 `agent_id` 的参数描述**只指向上下文清单**,不写 `bl` 命令;`bl` 出现在两处动态文本里,且**每次出现都带安装方式**(`npm install -g bailian-cli`):
100+
101+
- catalog 中真正推荐了命令的分支(截断、收敛到默认服务);
102+
- 4xx 之后追加的服务清单(`buildRefreshedSceneList`)。
103+
104+
带安装行是必需的:插件走 API 直连、从不 shell out,所以一个凭据齐全的部署完全可能没装 `bl`。而安装引导原本只写在 `skills/bailian-kb/SKILL.md` 的前置检查里,那是二阶决策——要模型先决定加载 skill 才读到,恰好漏掉走工具描述兜底的那个模型。
105+
90106
### 缓存与刷新
91107

92108
落点:`${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-<workspaceId>.json`(临时文件 + `rename()` 原子发布,目录 `0o700`)。按 workspace 分文件是必需的:api key 只能访问自己的 workspace,而"自动获取"按钮就是为了切账号。
@@ -108,7 +124,7 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两
108124

109125
## 错误语义
110126

111-
- HTTP 错误:4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效);5xx 与刷新本身失败则原错误透传;
127+
- HTTP 错误:4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效);刷新后该 scene **一个服务都没有**时也照样追加说明(明确"别换 id 重试"+ 建服务路径),而不是放裸错误过去——裸的 `invalid agent_id` 读起来就是"再试一个";5xx 与刷新本身失败则原错误透传;
112128
- 凭证缺失:指向 `~/.dsh/.env` / `.credentials.yaml` 配置方式与控制台取 key 页面;
113129
- chat 超时:说明服务端多轮检索特性,建议重试或改用 `kb_search`;
114130
- 服务端错误体截断至 500 字符进入错误信息(优先 `code: message`)。

packages/bailian-kb-dsh/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Retrieval happens through **retrieval services** you deploy on Bailian: a servic
3232
- DeepSeek Harness with its plugin runtime (`@deepseek-ai/dsh-*`), Node.js >= 22.12
3333
- An Aliyun Model Studio account: a **workspace id** and a **DashScope API key** ([get one](https://bailian.console.aliyun.com/?tab=app#/api-key))
3434
- 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`
35+
- 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
3536

3637
## Installation
3738

packages/bailian-kb-dsh/README.zh.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- DeepSeek Harness 及其插件运行时(`@deepseek-ai/dsh-*`),Node.js >= 22.12
3333
- 阿里云百炼账号:一个**工作空间 ID** 和一个 **DashScope API 密钥**[去获取](https://bailian.console.aliyun.com/?tab=app#/api-key)
3434
- 该工作空间下至少有一个**已部署**的检索或问答服务 —— 可在[控制台](https://bailian.console.aliyun.com/)创建,或用 `bl knowledge service create` / `bl knowledge service deploy`
35+
- 管理面的事(建库、导入文档、部署服务)需要 [`bl` CLI](https://www.npmjs.com/package/bailian-cli)`npm install -g bailian-cli`)。检索本身直连 API、从不起子进程,所以 `kb_search` / `kb_chat` 不装也能用
3536

3637
## 安装
3738

packages/bailian-kb-dsh/src/index.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { consoleLoginState, startConsoleLogin } from "./console-login.js";
1919
import { KbClient } from "./client.js";
2020
import { registerSkill } from "./skill.js";
2121
import { ServiceCache } from "./service-cache.js";
22-
import { CATALOG_ENTRY_LIMIT } from "./service-catalog.js";
22+
import { buildRefreshedSceneList } from "./service-catalog.js";
2323
import { installServiceContext } from "./service-context.js";
2424
import type { ServiceScene } from "./api-types.js";
2525
import { createKbTools } from "./tools.js";
@@ -333,28 +333,15 @@ export function apply(ctx: Context, config: Config): void {
333333
resolveDefaultRetrieveAgentId: async () => await resolveDefaultAgentId("search"),
334334
resolveDefaultChatAgentId: async () => await resolveDefaultAgentId("chat"),
335335
// Self-heal for a cached id the server has since rejected: refresh once and
336-
// put the current list in the error, which reaches the model this step.
336+
// put the current list in the error, which reaches the model this step. An
337+
// empty result is reported too rather than dropped — a bare "invalid
338+
// agent_id" reads as "try another one", and when nothing is deployed no id
339+
// can work.
337340
describeServicesAfterRefresh: async (scene) => {
338341
await serviceCache.refresh();
339342
const workspaceId = await resolveWorkspaceIdOrUndefined();
340343
if (workspaceId === undefined) return undefined;
341-
const forScene =
342-
serviceCache.peek(workspaceId)?.entries.filter((entry) => entry.scene === scene) ?? [];
343-
if (forScene.length === 0) return undefined;
344-
const lines = forScene
345-
.slice(0, CATALOG_ENTRY_LIMIT)
346-
.map(
347-
(entry) =>
348-
`- ${entry.agent_id}${entry.agent_name === "" ? "(unnamed)" : entry.agent_name}`,
349-
);
350-
const more = forScene.length - lines.length;
351-
return [
352-
`Deployed ${scene} services in this workspace, re-read just now:`,
353-
...lines,
354-
...(more > 0
355-
? [`(and ${more} more — \`bl knowledge service list --scene ${scene}\`)`]
356-
: []),
357-
].join("\n");
344+
return buildRefreshedSceneList(scene, serviceCache.entriesFor(workspaceId, scene));
358345
},
359346
get chatTimeoutMs() {
360347
return current().chatTimeoutMs;

packages/bailian-kb-dsh/src/service-catalog.ts

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* Renders the deployed-service catalog the model reads before deciding whether
3-
* to retrieve.
3+
* to retrieve, plus the notice for a workspace that deploys nothing at all.
44
*
5-
* This is a pure function on purpose: the four selection branches below are the
5+
* These are pure functions on purpose: the selection branches below are the
66
* whole routing policy, and they are far easier to pin down in tests than
77
* through a live pre-step.
88
*
@@ -12,7 +12,11 @@
1212
* - Truncation is always stated. Silently cutting the list makes the model treat
1313
* it as complete and flatly answer "there is no such knowledge base".
1414
* - An empty scene omits its whole section. `no chat services` is pure noise and
15-
* invites the model to handle a case that does not exist.
15+
* invites the model to handle a case that does not exist. An empty CATALOG is
16+
* the opposite case and does get a message: see {@link buildNoServiceNotice}.
17+
* - `bl` is only ever recommended together with how to get it. This plugin talks
18+
* to the API directly and never shells out, so a fully configured deployment
19+
* can have no `bl` on PATH at all.
1620
*/
1721

1822
import type { ServiceScene } from "./api-types.js";
@@ -24,6 +28,17 @@ export const CATALOG_ENTRY_LIMIT = 10;
2428
/** Truncation applied to a service description once the backend returns one. */
2529
const DESCRIPTION_LIMIT = 200;
2630

31+
/**
32+
* Appended once whenever the text tells the model to look further with `bl`.
33+
*
34+
* The install line belongs next to the recommendation, not in the tool
35+
* descriptions: those are static and every token there is spent on every
36+
* request, while this is needed only in the branches that actually name the
37+
* command. A single-service catalog never carries it.
38+
*/
39+
const BL_AVAILABILITY_NOTE =
40+
"(`bl` is the Bailian CLI — install it with `npm install -g bailian-cli` if the command is not found.)";
41+
2742
export interface CatalogInput {
2843
entries: readonly ServiceEntry[];
2944
/** Server-reported total, which may exceed `entries` when the fetch itself was capped. */
@@ -74,7 +89,7 @@ function renderScene(
7489
scene: ServiceScene,
7590
defaultAgentId: string | undefined,
7691
truncatedFetch: boolean,
77-
): string[] | undefined {
92+
): { lines: string[]; usedLookupHint: boolean } | undefined {
7893
const forScene = entries.filter((entry) => entry.scene === scene);
7994
if (forScene.length === 0) return undefined;
8095
const lines = [`${SCENE_LABEL[scene]}:`];
@@ -93,13 +108,14 @@ function renderScene(
93108
` (default service; ${others} other${others === 1 ? "" : "s"} exist — ` +
94109
`run \`bl knowledge service list --scene ${scene}\` to see them)`,
95110
);
111+
return { lines, usedLookupHint: true };
96112
}
97-
return lines;
113+
return { lines, usedLookupHint: false };
98114
}
99115

100116
if (forScene.length <= CATALOG_ENTRY_LIMIT && !truncatedFetch) {
101117
lines.push(...forScene.map(renderEntry));
102-
return lines;
118+
return { lines, usedLookupHint: false };
103119
}
104120

105121
const shown = [...forScene].sort(byRecency).slice(0, CATALOG_ENTRY_LIMIT);
@@ -112,7 +128,86 @@ function renderScene(
112128
`${knownTotal} deployed ${scene} services — run \`bl knowledge service list --scene ${scene} ` +
113129
"--name <keyword>` to look for others)",
114130
);
115-
return lines;
131+
return { lines, usedLookupHint: true };
132+
}
133+
134+
/**
135+
* The notice injected when the workspace is reachable but deploys nothing the
136+
* tools can call.
137+
*
138+
* This deliberately contradicts the "omit what does not exist" rule above, and
139+
* the cases are not symmetric: an empty SECTION is noise because the other
140+
* section still hands the model ids, whereas an empty CATALOG leaves it with no
141+
* id at all while `agent_id` stays required. With nothing said, the model either
142+
* invents an id — earning a raw server rejection — or silently drops the
143+
* retrieval; both read to the user as a broken plugin when the real answer is
144+
* "deploy a service". Naming the fix is the only way out that does not require
145+
* the model to already have loaded the management skill.
146+
* @returns the model-facing notice text.
147+
*/
148+
export function buildNoServiceNotice(): string {
149+
return [
150+
"<system-reminder>",
151+
"The Bailian knowledge tools (kb_search / kb_chat) are configured, but this workspace has no " +
152+
"deployed knowledge service, so neither tool can be called: both require an `agent_id` and there " +
153+
"is none to use. Do not call them, and do not invent an id.",
154+
"",
155+
"If the user asks for something that should come from their knowledge base, tell them a service has " +
156+
"to be created and deployed first — in the Bailian console (https://bailian.console.aliyun.com/), " +
157+
"or with `bl knowledge service create` followed by `bl knowledge service deploy`. " +
158+
BL_AVAILABILITY_NOTE,
159+
"</system-reminder>",
160+
].join("\n");
161+
}
162+
163+
/**
164+
* The service list appended to a call the server rejected, rendered from a
165+
* just-refreshed cache.
166+
*
167+
* Lives here rather than at the call site so every model-facing rendering obeys
168+
* the one set of conventions documented at the top of this file — in particular
169+
* that a shortfall is stated and that `bl` never appears without its install
170+
* line.
171+
* @param scene - the scene the failed call targeted.
172+
* @param entries - that scene's entries as of the refresh, newest first.
173+
* @returns the text to append to the failure.
174+
*/
175+
export function buildRefreshedSceneList(
176+
scene: ServiceScene,
177+
entries: readonly ServiceEntry[],
178+
): string {
179+
if (entries.length === 0) return buildNoSceneServiceNotice(scene);
180+
const shown = entries.slice(0, CATALOG_ENTRY_LIMIT);
181+
const more = entries.length - shown.length;
182+
return [
183+
`Deployed ${scene} services in this workspace, re-read just now:`,
184+
...shown.map(renderEntry),
185+
...(more > 0
186+
? [
187+
`(and ${more} more — run \`bl knowledge service list --scene ${scene}\` to see them) ` +
188+
BL_AVAILABILITY_NOTE,
189+
]
190+
: []),
191+
].join("\n");
192+
}
193+
194+
/**
195+
* What a rejected call is told when the refreshed list holds no service for that
196+
* scene at all.
197+
*
198+
* The tool has already failed by this point, so this text is the model's only
199+
* instruction. Without it the bare server rejection ("invalid agent_id") invites
200+
* a retry with another guess, and no guess can succeed.
201+
* @param scene - the scene whose services came back empty.
202+
* @returns the text to append to the failure.
203+
*/
204+
function buildNoSceneServiceNotice(scene: ServiceScene): string {
205+
return (
206+
`This workspace has no deployed ${scene} services at all (re-checked just now), so no agent_id can ` +
207+
"work — do not retry with a different id. Tell the user one has to be created and deployed, in the " +
208+
"Bailian console (https://bailian.console.aliyun.com/) or with `bl knowledge service create` " +
209+
`followed by \`bl knowledge service deploy\`. ${BL_AVAILABILITY_NOTE}`
210+
);
116211
}
117212

118213
/**
@@ -129,6 +224,7 @@ export function buildServiceCatalog(input: CatalogInput): string | undefined {
129224
);
130225
const chat = renderScene(input.entries, "chat", input.defaultChatAgentId, input.truncated);
131226
if (search === undefined && chat === undefined) return undefined;
227+
const usedLookupHint = search?.usedLookupHint === true || chat?.usedLookupHint === true;
132228
return [
133229
"<system-reminder>",
134230
// 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 {
138234
"Pass an id from the matching section as that tool's `agent_id` argument — it is required and " +
139235
"cannot be guessed.",
140236
"",
141-
...(search ?? []),
237+
...(search?.lines ?? []),
142238
...(search !== undefined && chat !== undefined ? [""] : []),
143-
...(chat ?? []),
239+
...(chat?.lines ?? []),
144240
"",
145241
"If none of these services covers what the user is asking about, say so plainly rather than " +
146242
"trying the closest-looking id — an unrelated retrieval result is worse than none.",
243+
...(usedLookupHint ? [BL_AVAILABILITY_NOTE] : []),
147244
"</system-reminder>",
148245
].join("\n");
149246
}

packages/bailian-kb-dsh/src/service-context.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type { Context } from "@deepseek-ai/cordis";
2525
import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
2626
import { createUserMessage } from "@deepseek-ai/dsh-llm";
2727
import type { UserMessage } from "@deepseek-ai/dsh-session";
28-
import { buildServiceCatalog } from "./service-catalog.js";
28+
import { buildNoServiceNotice, buildServiceCatalog } from "./service-catalog.js";
2929
import type { ServiceCache } from "./service-cache.js";
3030

3131
/** Marks this plugin's own injections in the durable log. */
@@ -102,20 +102,26 @@ export function installServiceContext(ctx: Context, opts: ServiceContextOptions)
102102
if (opts.cache.isStale(workspaceId)) void opts.cache.refresh();
103103

104104
const document = opts.cache.peek(workspaceId);
105+
// No document at all means the first fetch has not landed (or is
106+
// failing): we know nothing, so claiming anything would be a guess. The
107+
// refresh above self-corrects this on a later step.
105108
if (document === undefined) return decision;
106109
const [defaultRetrieveAgentId, defaultChatAgentId] = await Promise.all([
107110
opts.resolveDefaultRetrieveAgentId(),
108111
opts.resolveDefaultChatAgentId(),
109112
]);
110113
if (signal.aborted) return decision;
111-
const text = buildServiceCatalog({
112-
entries: document.entries,
113-
total: document.total,
114-
truncated: document.truncated,
115-
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
116-
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
117-
});
118-
if (text === undefined) return decision;
114+
// A document with zero entries is the opposite: an authoritative "this
115+
// workspace deploys nothing callable". Saying so beats silence, which
116+
// leaves the model to invent a required agent_id.
117+
const text =
118+
buildServiceCatalog({
119+
entries: document.entries,
120+
total: document.total,
121+
truncated: document.truncated,
122+
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
123+
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
124+
}) ?? buildNoServiceNotice();
119125

120126
// Identical to what the model already sees: stay out of the way. This is
121127
// the branch that runs on nearly every step.

0 commit comments

Comments
 (0)