mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
docs(kb-dsh): 优化知识服务清单注入与错误处理提示
- 补查型工具(service_find)确认不暴露服务清单,避免与catalog冲突 - 清单内容策略扩展,0服务时注入明确禁止猜测id的提示 - 工具描述保持静态,上下文消息注入带source的UserMessage实现动态清单 - bl命令及安装提示仅在动态文本中出现,避免静态描述频繁消耗token - 错误处理中4xx刷新并追加服务清单,0服务状态下明确提示不重试须创建部署 - service-catalog新增无服务提示及刷新服务列表构建函数 - service-context调整使用新清单构建逻辑,缓存空时注入无服务通知 - tools调整描述文案,提示来自上下文消息且拒绝猜测 - README补充bl CLI安装使用说明 - 测试补充无服务情况注入提示及刷新列表文本内容校验
This commit is contained in:
@@ -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-<workspaceId>.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`)。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 不装也能用
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <keyword>` 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 [
|
||||
"<system-reminder>",
|
||||
"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,
|
||||
"</system-reminder>",
|
||||
].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 [
|
||||
"<system-reminder>",
|
||||
// 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] : []),
|
||||
"</system-reminder>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <keyword>` 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<string> => {
|
||||
if (supplied !== undefined) return supplied;
|
||||
|
||||
@@ -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<ServiceEntry> & { 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ interface HarnessOptions {
|
||||
entries?: { agent_id: string; agent_name: string; scene: "search" | "chat"; status: string }[];
|
||||
cacheOverride?: ServiceCache;
|
||||
postJson?: () => Promise<unknown>;
|
||||
/** 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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user