Files
modelstudioai__cli/docs/kb-dsh/2026-08-15-bailian-kb-bundle-plan.md
zeyu.fz d24104f7dc feat(kb-dsh): 接入仓库工程约定并改名为公开包 bailian-kb-dsh
包名 @ali/bailian-kb-dsh → bailian-kb-dsh(公开 npm):package.json name +
cordis.patch.yml insert.name + tsdown PLUGIN_ID 三处同步(漏一处即 dsh 运行时崩)。

产物 lib/ → dist/(本仓 .gitignore 忽略 dist 不忽略 lib),连带 main/types/
exports/files/tsdown outDir 同步;.gitignore 补 *.tsbuildinfo。

依赖接 catalog(yaml/typescript/@types/node/vite-plus);测试导入 vitest →
vite-plus/test(全仓统一约定,消掉唯一的 vitest 依赖漂移)。

tsconfig 拆三件套:tsconfig.json 纯类型检查覆盖 src+tests(供 oxlint 自动发现,
含 jsx/DOM),tsconfig.build.json 产出 node 半,tsconfig.web.json 隔离检查 web 半。
补齐 tests 从未被类型检查暴露的一处 partial 输入类型错误。

根 vite.config.ts 新增两条 override:web 半 no-restricted-imports 把 tsdown 构建期
的 bundle purity gate 提前到 lint 期;全包放开 _ 前缀的 no-unused-vars。

文档:新增 docs/agents/dsh-plugin.md,AGENTS.md 项目地图/版本锁步例外/分层边界/
场景索引同步,packages.mjs 注释说明故意不进发布白名单。

格式化(单引号无分号 → 双引号加分号)由 pre-commit 的 vp check --fix 自动完成,
无法单独成 commit,一并纳入。

全仓 vp check 0 error;插件 13 文件 85 测试全绿;build + typecheck 通过。
2026-08-24 11:02:44 +08:00

59 KiB
Raw Permalink Blame History

bailian-kb-bundle 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在 /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle 新建独立 pnpm workspace,交付一个可通过 dsh plugin add 安装的 dsh bundle:三个 API 直连知识库工具(kb_service_list / kb_search / kb_chat)+ kscli 管理面 skill。

Architecture: 两包结构——dsh-tool-bailian-kb(Cordis 函数插件:Config、KbClient、工具工厂、skill 注册)+ bailian-kb-bundle(dsh.bundle 分发面:cordis.patch.yml)。工具逻辑与 Cordis 解耦为纯函数/类(endpoints、client、SSE、services、tools 工厂),apply() 只做装配,测试不需要 Cordis 容器。

Tech Stack: TypeScript strict + NodeNext ESM(相对导入用 .js 后缀)、@deepseek-ai/cordis@4.0.1(peer+dev)、@deepseek-ai/dsh-tools、@deepseek-ai/dsh-credentials、@deepseek-ai/dsh-skill(类型)、@deepseek-ai/schemastery@3.18.1、vitest、tsc 构建(输出 lib/)。

设计依据(spec): deepseek-harness/docs/superpowers/specs/2026-08-15-bailian-kb-bundle-design.md


已验证的 API 事实(实现的唯一依据,来自 modelstudioai/cli 源码)

所有 endpoint 的 host 为 workspace 子域名:https://${workspaceId}.${endpointHost},默认 endpointHost = cn-beijing.maas.aliyuncs.com。鉴权统一 Authorization: Bearer ${DASHSCOPE_API_KEY}。

1. 服务列表 POST /api/v1/indices/rag/app/list 请求体:{ agent_scene: 'chat'|'search'(必填), agent_name?: string, page_number: number, page_size: number(1-100) } 响应:{ code, message, data: { total_count?: number, rows?: Array<{ agent_id?, agent_name?, agent_scene?, agent_status?, pipeline_list?: Array<{ pipeline_id?, pipeline_name? }> }> } }

2. 检索 POST /api/v1/indices/knowledge/search 请求体:{ query: string, agent_id: string, agent_version?: string, images?: string[] }(无 top_k 参数,条数由服务端配置决定;top_k 为客户端截断) 响应:{ code, status_code, request_id, data: { total, cost_time, nodes: Array<{ score: number, text: string, metadata: { title?, doc_id?, doc_name?, doc_url?, page_number?, ... } }> } }

3. 问答 POST /api/v2/apps/knowledge/chat(仅 SSE) 请求体:{ input: { messages: Array<{ role: 'user'|'assistant', content: string }> }, parameters: { agent_options: { agent_id, agent_version? } }, stream: true } SSE:data: [DONE] 结束;event: error 的 data 为 { code?, message? };普通 chunk: { output: { choices: Array<{ message: { role, content, extra?: { step_change?, step?, group? } }, finish_reason: string }> }, code, message, request_id } content 为增量(delta),拼接即完整答案;事件序列 tool_calling → tool_return → plan_start → planning → plan_end → generation_start → generating → generation_end,tool_calling→tool_return 可循环多次(服务端 agentic loop,耗时可达分钟级)。

4. dsh 侧已验证接口:ctx.tools.register(defineTool({...}))(模板:deepseek-harness packages/todo/tool-todo/src/index.ts);ctx.credentials.resolve(credentialRef('DASHSCOPE_API_KEY')) 返回 Promise<{ value, source } | undefined>,逐次调用不缓存;ctx.skills.register({ name, description, whenToUse?, content, source: 'bundled', resourceBase? }) 返回 disposer;bundle patch 语法为顶层 - insert: 行数组(模板:packages/bundle/base/cordis.patch.yml)。

文件结构

/Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle/
├── package.json                # 根:private,scripts(build/test/typecheck)
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── vitest.config.ts
├── .gitignore
└── packages/
    ├── tool-bailian-kb/
    │   ├── package.json
    │   ├── tsconfig.json
    │   ├── skills/bailian-kb-management/SKILL.md   # kscli 管理面 skill
    │   ├── src/
    │   │   ├── index.ts        # 插件入口:name/inject/Config/apply(只做装配)
    │   │   ├── api-types.ts    # 上述 API 请求/响应类型
    │   │   ├── endpoints.ts    # 纯函数:URL 拼接 + 协议路径常量
    │   │   ├── client.ts       # KbClient:Bearer 鉴权、JSON/SSE 请求、KbApiError
    │   │   ├── sse.ts          # 最小 SSE 解析器(async generator)
    │   │   ├── chat.ts         # consumeChatStream:SSE → 完整答案(缓冲)
    │   │   ├── services.ts     # listServices:scene 合并 + 分页内化 + 截断提示
    │   │   ├── tools.ts        # createKbTools:三个工具定义(纯工厂)
    │   │   └── skill.ts        # registerSkill:ctx.skills 运行时注册
    │   └── tests/
    │       ├── config.test.ts
    │       ├── endpoints.test.ts
    │       ├── client.test.ts
    │       ├── sse.test.ts
    │       ├── chat.test.ts
    │       ├── services.test.ts
    │       └── tools.test.ts
    └── bundle/
        ├── package.json        # bailian-kb-bundle:dsh.bundle 声明
        └── cordis.patch.yml    # insert 插件 row(workspaceId 走 !!js env)

Task 1: 仓库脚手架与依赖可用性验证

Files:

  • Create: package.json、pnpm-workspace.yaml、tsconfig.base.json、vitest.config.ts、.gitignore

  • Step 1: 验证 npm 依赖存在

npm view @deepseek-ai/cordis version && npm view @deepseek-ai/dsh-tools version && npm view @deepseek-ai/schemastery version && npm view @deepseek-ai/dsh-credentials version && npm view @deepseek-ai/dsh-skill version

Expected: 五行版本号(已确认 cordis 4.0.1、dsh-tools 0.0.1-rc.1、schemastery 3.18.1;后两个若 404,改用 github: 依赖或从 dsh 安装闭包解析——此时停下向用户报告,不要静默绕过)。

  • Step 2: 初始化目录与 git
mkdir -p /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle && cd /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle && git init
  • Step 3: 写根配置文件

package.json:

{
  "name": "bailian-kb-workspace",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "pnpm -r run build",
    "test": "vitest run",
    "typecheck": "tsc -b packages/tool-bailian-kb"
  },
  "devDependencies": {
    "typescript": "^5.7.2",
    "vitest": "^3.0.0"
  }
}

pnpm-workspace.yaml:

packages:
  - packages/*

tsconfig.base.json:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { include: ["packages/*/tests/**/*.test.ts"] },
});

.gitignore:

node_modules/
lib/
*.tsbuildinfo
  • Step 4: Commit
git add -A && git commit -m "chore: scaffold pnpm workspace"

Task 2: 插件包骨架与 Config schema

Files:

  • Create: packages/tool-bailian-kb/package.json、packages/tool-bailian-kb/tsconfig.json、packages/tool-bailian-kb/src/index.ts(先只有 Config)

  • Test: packages/tool-bailian-kb/tests/config.test.ts

  • Step 1: 写包配置

packages/tool-bailian-kb/package.json:

{
  "name": "dsh-tool-bailian-kb",
  "version": "0.1.0",
  "description": "Bailian knowledge-base tools for DeepSeek Harness: kb_service_list, kb_search, kb_chat over the DashScope RAG API, plus the kscli management skill.",
  "type": "module",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "exports": {
    ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
    "./package.json": "./package.json"
  },
  "files": ["lib", "skills"],
  "scripts": { "build": "tsc -b" },
  "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1" },
  "dependencies": {
    "@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
    "@deepseek-ai/dsh-credentials": "*",
    "@deepseek-ai/dsh-skill": "*",
    "@deepseek-ai/schemastery": "^3.18.1"
  },
  "devDependencies": { "@deepseek-ai/cordis": "^4.0.1", "@types/node": "^22.0.0" }
}

(* 处安装后锁到 pnpm install 解析出的当前 rc 版本,手动改回具体 ^ 范围。)

packages/tool-bailian-kb/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "rootDir": "src", "outDir": "lib" },
  "include": ["src"]
}
  • Step 2: 写失败测试

packages/tool-bailian-kb/tests/config.test.ts:

import { describe, expect, it } from "vitest";
import { Config } from "../src/index.js";

describe("Config", () => {
  it("applies defaults and keeps required workspaceId", () => {
    const resolved = new Config({ workspaceId: "ws-1" });
    expect(resolved.workspaceId).toBe("ws-1");
    expect(resolved.endpointHost).toBe("cn-beijing.maas.aliyuncs.com");
    expect(resolved.chatTimeoutMs).toBe(300_000);
    expect(resolved.defaultAgentId).toBeUndefined();
  });

  it("rejects a missing workspaceId (fail loud at load)", () => {
    expect(() => new Config({} as never)).toThrow();
  });
});
  • Step 3: 运行确认失败
cd /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle && pnpm install && pnpm vitest run packages/tool-bailian-kb/tests/config.test.ts

Expected: FAIL(../src/index.js 不存在)。

  • Step 4: 实现 Config

packages/tool-bailian-kb/src/index.ts:

/**
 * Bailian knowledge-base consumer plugin: registers kb_service_list, kb_search,
 * and kb_chat over the DashScope RAG API, plus the kscli management skill.
 * @module dsh-tool-bailian-kb
 */

import z from "@deepseek-ai/schemastery";

export const name = "tool-bailian-kb";
export const inject = ["tools", "credentials"];

/** Bailian knowledge-base plugin configuration. */
export interface Config {
  /** Bailian workspace id; the API host is the workspace subdomain `https://<workspaceId>.<endpointHost>`. */
  workspaceId: string;
  /** API host suffix; replace for other regions or private deployments. */
  endpointHost: string;
  /** Retrieval-service id pinned by this deployment; when set, the tools' agent_id parameter becomes optional. */
  defaultAgentId?: string;
  /** Service version to call: `beta` (draft) or a published number; defaults to the latest published version. Never model-visible. */
  agentVersion?: string;
  /** kb_chat timeout in milliseconds; the server side is a minutes-scale agentic loop. */
  chatTimeoutMs: number;
}

/** Schemastery validation for {@link Config}; a missing workspaceId fails at load. */
export const Config: z<Config> = z.object({
  workspaceId: z.string().required(),
  endpointHost: z.string().default("cn-beijing.maas.aliyuncs.com"),
  defaultAgentId: z.string(),
  agentVersion: z.string(),
  chatTimeoutMs: z.number().default(300_000),
});
  • Step 5: 运行确认通过 & Commit
pnpm vitest run packages/tool-bailian-kb/tests/config.test.ts
git add -A && git commit -m "feat: plugin package skeleton with validated Config"

Task 3: endpoints 纯函数与 API 类型

Files:

  • Create: packages/tool-bailian-kb/src/endpoints.ts、packages/tool-bailian-kb/src/api-types.ts

  • Test: packages/tool-bailian-kb/tests/endpoints.test.ts

  • Step 1: 写失败测试

packages/tool-bailian-kb/tests/endpoints.test.ts:

import { describe, expect, it } from "vitest";
import { KB_PATHS, kbEndpoint } from "../src/endpoints.js";

describe("kbEndpoint", () => {
  it("builds the workspace-subdomain URL", () => {
    expect(kbEndpoint("cn-beijing.maas.aliyuncs.com", "ws-1", KB_PATHS.search)).toBe(
      "https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search",
    );
  });

  it("keeps protocol paths as constants", () => {
    expect(KB_PATHS.chat).toBe("/api/v2/apps/knowledge/chat");
    expect(KB_PATHS.serviceList).toBe("/api/v1/indices/rag/app/list");
  });
});
  • Step 2: 运行确认失败
pnpm vitest run packages/tool-bailian-kb/tests/endpoints.test.ts

Expected: FAIL(模块不存在)。

  • Step 3: 实现

packages/tool-bailian-kb/src/endpoints.ts:

/** Protocol path constants and the workspace-subdomain URL builder (external API spec; not configurable). */

/** DashScope knowledge API paths, mirrored from the verified kscli endpoint table. */
export const KB_PATHS = {
  serviceList: "/api/v1/indices/rag/app/list",
  search: "/api/v1/indices/knowledge/search",
  chat: "/api/v2/apps/knowledge/chat",
} as const;

/**
 * Build one knowledge API endpoint.
 * @param endpointHost - host suffix, e.g. `cn-beijing.maas.aliyuncs.com`.
 * @param workspaceId - Bailian workspace id used as the subdomain.
 * @param path - one {@link KB_PATHS} value.
 * @returns the absolute endpoint URL.
 */
export function kbEndpoint(endpointHost: string, workspaceId: string, path: string): string {
  return `https://${workspaceId}.${endpointHost}${path}`;
}

packages/tool-bailian-kb/src/api-types.ts:

/** Request/response fields of the three DashScope knowledge endpoints, mirrored from the verified kscli types. */

export interface ServiceListRequest {
  agent_scene: "chat" | "search";
  agent_name?: string;
  page_number: number;
  page_size: number;
}

export interface ServiceListRow {
  agent_id?: string;
  agent_name?: string;
  agent_scene?: string;
  agent_status?: string;
  pipeline_list?: { pipeline_id?: string; pipeline_name?: string }[];
}

export interface ServiceListResponse {
  code?: string;
  message?: string;
  data?: { total_count?: number; rows?: ServiceListRow[] };
}

export interface SearchRequest {
  query: string;
  agent_id: string;
  agent_version?: string;
  images?: string[];
}

export interface SearchResponse {
  request_id?: string;
  data?: {
    total?: number;
    nodes?: { score: number; text: string; metadata?: Record<string, unknown> }[];
  };
}

export interface ChatRequest {
  input: { messages: { role: "user" | "assistant"; content: string }[] };
  parameters: { agent_options: { agent_id: string; agent_version?: string } };
  stream: true;
}

export interface ChatStreamChunk {
  output?: {
    choices?: {
      message?: { content?: string; extra?: { step_change?: string } };
      finish_reason?: string;
    }[];
  };
  request_id?: string;
}
  • Step 4: 运行确认通过 & Commit
pnpm vitest run packages/tool-bailian-kb/tests/endpoints.test.ts
git add -A && git commit -m "feat: endpoint builder and API types"

Task 4: KbClient(鉴权、JSON/SSE 请求、错误翻译)

Files:

  • Create: packages/tool-bailian-kb/src/client.ts

  • Test: packages/tool-bailian-kb/tests/client.test.ts

  • Step 1: 写失败测试

packages/tool-bailian-kb/tests/client.test.ts:

import { describe, expect, it, vi } from "vitest";
import { KbApiError, KbClient } from "../src/client.js";

function makeClient(fetchImpl: typeof fetch) {
  return new KbClient({
    workspaceId: "ws-1",
    endpointHost: "cn-beijing.maas.aliyuncs.com",
    resolveApiKey: async () => "sk-test",
    fetchImpl,
  });
}

describe("KbClient.postJson", () => {
  it("sends Bearer auth to the workspace endpoint and returns parsed JSON", async () => {
    const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: 1 }), { status: 200 }));
    const client = makeClient(fetchImpl as unknown as typeof fetch);
    const result = await client.postJson<{ ok: number }>("/api/v1/indices/knowledge/search", {
      query: "q",
    });
    expect(result.ok).toBe(1);
    const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
    expect(url).toBe("https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search");
    expect((init.headers as Record<string, string>).Authorization).toBe("Bearer sk-test");
    expect(init.method).toBe("POST");
  });

  it("translates a non-2xx into KbApiError with status and a bounded body summary", async () => {
    const body = JSON.stringify({ code: "InvalidParameter", message: "agent not found" });
    const fetchImpl = vi.fn(async () => new Response(body, { status: 400 }));
    const client = makeClient(fetchImpl as unknown as typeof fetch);
    const err = await client
      .postJson("/api/v1/indices/knowledge/search", {})
      .catch((e: unknown) => e);
    expect(err).toBeInstanceOf(KbApiError);
    expect((err as KbApiError).status).toBe(400);
    expect((err as KbApiError).message).toContain("agent not found");
  });

  it("re-resolves the API key per call (credential hot-swap contract)", async () => {
    const resolveApiKey = vi.fn(async () => "sk-test");
    const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 }));
    const client = new KbClient({
      workspaceId: "ws-1",
      endpointHost: "h",
      resolveApiKey,
      fetchImpl: fetchImpl as unknown as typeof fetch,
    });
    await client.postJson("/p", {});
    await client.postJson("/p", {});
    expect(resolveApiKey).toHaveBeenCalledTimes(2);
  });
});
  • Step 2: 运行确认失败
pnpm vitest run packages/tool-bailian-kb/tests/client.test.ts

Expected: FAIL(模块不存在)。

  • Step 3: 实现

packages/tool-bailian-kb/src/client.ts:

/** Shared HTTP client for the knowledge endpoints: per-call Bearer auth, JSON/SSE POST, and error translation. */

import { kbEndpoint } from "./endpoints.js";

/** Maximum error-body characters kept in a translated message. */
const ERROR_BODY_LIMIT = 500;

/** One knowledge API failure: HTTP status plus a bounded server-body summary. */
export class KbApiError extends Error {
  constructor(
    message: string,
    readonly status?: number,
  ) {
    super(message);
    this.name = "KbApiError";
  }
}

export interface KbClientOptions {
  workspaceId: string;
  endpointHost: string;
  /** Service version forwarded on search/chat when set (deployment debug choice). */
  agentVersion?: string;
  /** Resolves the current DASHSCOPE_API_KEY per call; throws with guidance when unconfigured. */
  resolveApiKey: () => Promise<string>;
  /** Test seam; defaults to global fetch. */
  fetchImpl?: typeof fetch;
}

export class KbClient {
  constructor(private readonly opts: KbClientOptions) {}

  /** The deployment's configured service version, exposed for request builders. */
  get agentVersion(): string | undefined {
    return this.opts.agentVersion;
  }

  private async post(
    path: string,
    body: unknown,
    accept: string,
    signal?: AbortSignal,
  ): Promise<Response> {
    const apiKey = await this.opts.resolveApiKey();
    const fetchImpl = this.opts.fetchImpl ?? fetch;
    const url = kbEndpoint(this.opts.endpointHost, this.opts.workspaceId, path);
    const res = await fetchImpl(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        Accept: accept,
      },
      body: JSON.stringify(body),
      signal,
    });
    if (!res.ok) {
      const raw = (await res.text().catch(() => "")).slice(0, ERROR_BODY_LIMIT);
      let detail = raw;
      try {
        const parsed = JSON.parse(raw) as { message?: string; code?: string };
        if (parsed.message)
          detail = parsed.code ? `${parsed.code}: ${parsed.message}` : parsed.message;
      } catch {
        /* non-JSON error body: keep the bounded raw text */
      }
      throw new KbApiError(
        `knowledge API ${path} failed (HTTP ${res.status}): ${detail}`,
        res.status,
      );
    }
    return res;
  }

  /**
   * POST one JSON request and parse the JSON response.
   * @param path - one KB_PATHS value.
   * @param body - JSON-serializable request body.
   * @param signal - optional abort/timeout signal.
   * @returns the parsed response.
   */
  async postJson<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
    const res = await this.post(path, body, "application/json", signal);
    return (await res.json()) as T;
  }

  /**
   * POST one JSON request expecting an SSE response stream.
   * @param path - one KB_PATHS value.
   * @param body - JSON-serializable request body.
   * @param signal - abort/timeout signal (kb_chat passes its configured timeout).
   * @returns the raw Response whose body is the SSE stream.
   */
  async postSse(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
    return await this.post(path, body, "text/event-stream", signal);
  }
}
  • Step 4: 运行确认通过 & Commit
pnpm vitest run packages/tool-bailian-kb/tests/client.test.ts
git add -A && git commit -m "feat: KbClient with per-call auth and error translation"

Task 5: SSE 解析与 chat 缓冲消费

Files:

  • Create: packages/tool-bailian-kb/src/sse.ts、packages/tool-bailian-kb/src/chat.ts

  • Test: packages/tool-bailian-kb/tests/sse.test.ts、packages/tool-bailian-kb/tests/chat.test.ts

  • Step 1: 写失败测试(SSE 解析器)

packages/tool-bailian-kb/tests/sse.test.ts:

import { describe, expect, it } from "vitest";
import { parseSseStream } from "../src/sse.js";

function streamOf(text: string): ReadableStream<Uint8Array> {
  return new Response(text).body as ReadableStream<Uint8Array>;
}

async function collect(text: string) {
  const events: { event?: string; data: string }[] = [];
  for await (const e of parseSseStream(streamOf(text))) events.push(e);
  return events;
}

describe("parseSseStream", () => {
  it("yields data events split on blank lines", async () => {
    const events = await collect('data: {"a":1}\n\ndata: [DONE]\n\n');
    expect(events).toEqual([
      { event: undefined, data: '{"a":1}' },
      { event: undefined, data: "[DONE]" },
    ]);
  });

  it("carries the event field and survives chunk boundaries inside a line", async () => {
    const events = await collect('event: error\ndata: {"message":"boom"}\n\n');
    expect(events[0]).toEqual({ event: "error", data: '{"message":"boom"}' });
  });
});
  • Step 2: 运行确认失败
pnpm vitest run packages/tool-bailian-kb/tests/sse.test.ts

Expected: FAIL(模块不存在)。

  • Step 3: 实现 SSE 解析器

packages/tool-bailian-kb/src/sse.ts:

/** Minimal SSE parser for the knowledge chat stream: `event:`/`data:` lines, events split on blank lines. */

export interface SseEvent {
  event?: string;
  data: string;
}

/**
 * Parse one SSE byte stream into events.
 * @param body - the response body stream.
 * @returns events in stream order; multi-`data:` events join with newlines per the SSE spec.
 */
export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
  const decoder = new TextDecoder();
  let buffer = "";
  let event: string | undefined;
  let data: string[] = [];

  const flush = (): SseEvent | undefined => {
    if (data.length === 0) return undefined;
    const out = { event, data: data.join("\n") };
    event = undefined;
    data = [];
    return out;
  };

  const reader = body.getReader();
  while (true) {
    const { done, value } = await reader.read();
    buffer += done ? "" : decoder.decode(value, { stream: true });
    let newline: number;
    while ((newline = buffer.indexOf("\n")) !== -1) {
      const line = buffer.slice(0, newline).replace(/\r$/, "");
      buffer = buffer.slice(newline + 1);
      if (line === "") {
        const out = flush();
        if (out) yield out;
      } else if (line.startsWith("event:")) {
        event = line.slice(6).trim();
      } else if (line.startsWith("data:")) {
        data.push(line.slice(5).trimStart());
      }
      // comment/id/retry lines are irrelevant to this API and are skipped
    }
    if (done) {
      const out = flush();
      if (out) yield out;
      return;
    }
  }
}
  • Step 4: SSE 测试通过后,写失败测试(chat 缓冲)
pnpm vitest run packages/tool-bailian-kb/tests/sse.test.ts   # Expected: PASS

packages/tool-bailian-kb/tests/chat.test.ts:

import { describe, expect, it } from "vitest";
import { consumeChatStream } from "../src/chat.js";

function sse(text: string): Response {
  return new Response(text, { status: 200 });
}

function chunk(content: string, finish = ""): string {
  return `data: ${JSON.stringify({ output: { choices: [{ message: { content }, finish_reason: finish }] }, request_id: "r-1" })}\n\n`;
}

describe("consumeChatStream", () => {
  it("concatenates delta content across chunks until [DONE]", async () => {
    const res = sse(chunk("Hello") + chunk(" world", "stop") + "data: [DONE]\n\n");
    const out = await consumeChatStream(res);
    expect(out.answer).toBe("Hello world");
    expect(out.requestId).toBe("r-1");
  });

  it("ignores step_change progress chunks with empty content", async () => {
    const progress = `data: ${JSON.stringify({ output: { choices: [{ message: { content: "", extra: { step_change: "tool_calling" } }, finish_reason: "" }] } })}\n\n`;
    const res = sse(progress + chunk("answer", "stop") + "data: [DONE]\n\n");
    expect((await consumeChatStream(res)).answer).toBe("answer");
  });

  it("throws on an SSE error event with the server message", async () => {
    const res = sse('event: error\ndata: {"code":"Throttling","message":"rate limited"}\n\n');
    await expect(consumeChatStream(res)).rejects.toThrow(/Throttling.*rate limited/);
  });
});
  • Step 5: 运行确认失败,然后实现
pnpm vitest run packages/tool-bailian-kb/tests/chat.test.ts   # Expected: FAIL

packages/tool-bailian-kb/src/chat.ts:

/** Buffered consumption of the knowledge chat SSE stream: deltas concatenate into one complete answer. */

import type { ChatStreamChunk } from "./api-types.js";
import { KbApiError } from "./client.js";
import { parseSseStream } from "./sse.js";

export interface ChatResult {
  answer: string;
  requestId?: string;
}

/**
 * Consume one chat SSE response to completion.
 * @param res - the SSE response from KbClient.postSse.
 * @returns the concatenated answer and the last seen request id.
 */
export async function consumeChatStream(res: Response): Promise<ChatResult> {
  if (!res.body) throw new KbApiError("knowledge chat returned no response body");
  let answer = "";
  let requestId: string | undefined;
  for await (const event of parseSseStream(res.body)) {
    if (event.data === "[DONE]") break;
    if (event.event === "error") {
      let message = `knowledge chat stream error: ${event.data}`;
      try {
        const err = JSON.parse(event.data) as { code?: string; message?: string };
        if (err.message)
          message = `knowledge chat stream error${err.code ? ` (${err.code})` : ""}: ${err.message}`;
      } catch {
        /* non-JSON error payload: keep the raw data in the message */
      }
      throw new KbApiError(message);
    }
    let parsed: ChatStreamChunk;
    try {
      parsed = JSON.parse(event.data) as ChatStreamChunk;
    } catch {
      continue;
    } // unparseable keep-alive/comment payloads carry no answer content
    if (parsed.request_id) requestId = parsed.request_id;
    for (const choice of parsed.output?.choices ?? []) {
      if (choice.message?.content) answer += choice.message.content;
    }
  }
  return { answer, requestId };
}
  • Step 6: 运行确认通过 & Commit
pnpm vitest run packages/tool-bailian-kb/tests/sse.test.ts packages/tool-bailian-kb/tests/chat.test.ts
git add -A && git commit -m "feat: SSE parser and buffered chat consumption"

Task 6: listServices(scene 合并、分页内化、截断提示)

Files:

  • Create: packages/tool-bailian-kb/src/services.ts

  • Test: packages/tool-bailian-kb/tests/services.test.ts

  • Step 1: 写失败测试

packages/tool-bailian-kb/tests/services.test.ts:

import { describe, expect, it, vi } from "vitest";
import type { ServiceListResponse } from "../src/api-types.js";
import type { KbClient } from "../src/client.js";
import { listServices } from "../src/services.js";

function fakeClient(byScene: Record<string, ServiceListResponse>) {
  const postJson = vi.fn(
    async (_path: string, body: { agent_scene: string }) => byScene[body.agent_scene],
  );
  return { client: { postJson } as unknown as KbClient, postJson };
}

const row = (id: string, scene: string) => ({
  agent_id: id,
  agent_name: `svc-${id}`,
  agent_scene: scene,
  agent_status: "deployed",
  pipeline_list: [{ pipeline_id: "p1", pipeline_name: "kb-one" }],
});

describe("listServices", () => {
  it("queries both scenes when scene is omitted and merges rows with scene tags", async () => {
    const { client, postJson } = fakeClient({
      chat: { data: { total_count: 1, rows: [row("a", "chat")] } },
      search: { data: { total_count: 1, rows: [row("b", "search")] } },
    });
    const out = await listServices(client, {});
    expect(postJson).toHaveBeenCalledTimes(2);
    expect(out.services.map((s) => [s.agent_id, s.scene])).toEqual([
      ["a", "chat"],
      ["b", "search"],
    ]);
    expect(out.total).toBe(2);
    expect(out.truncated).toBe(false);
    const body = postJson.mock.calls[0]![1] as Record<string, unknown>;
    expect(body.page_number).toBe(1);
    expect(body.page_size).toBe(100);
  });

  it("queries one scene and forwards the name filter", async () => {
    const { client, postJson } = fakeClient({ search: { data: { total_count: 0, rows: [] } } });
    await listServices(client, { scene: "search", nameFilter: "客服" });
    expect(postJson).toHaveBeenCalledTimes(1);
    expect((postJson.mock.calls[0]![1] as Record<string, unknown>).agent_name).toBe("客服");
  });

  it("flags truncation when a scene exceeds one max page", async () => {
    const { client } = fakeClient({
      chat: { data: { total_count: 250, rows: [row("a", "chat")] } },
      search: { data: { total_count: 0, rows: [] } },
    });
    const out = await listServices(client, {});
    expect(out.truncated).toBe(true);
    expect(out.total).toBe(250);
  });
});
  • Step 2: 运行确认失败
pnpm vitest run packages/tool-bailian-kb/tests/services.test.ts

Expected: FAIL(模块不存在)。

  • Step 3: 实现

packages/tool-bailian-kb/src/services.ts:

/** Retrieval-service discovery: per-scene queries merged into one model-facing list; pagination stays internal. */

import type { ServiceListResponse } from "./api-types.js";
import type { KbClient } from "./client.js";
import { KB_PATHS } from "./endpoints.js";

/** Server page-size maximum; one page per scene covers ordinary workspaces. */
const MAX_PAGE_SIZE = 100;

export interface ServiceEntry {
  agent_id: string;
  name: string;
  scene: string;
  status: string;
  knowledge_bases: string[];
}

export interface ServiceList {
  services: ServiceEntry[];
  total: number;
  /** True when some scene reported more rows than one max page returned. */
  truncated: boolean;
}

export interface ListServicesQuery {
  scene?: "chat" | "search";
  nameFilter?: string;
}

/**
 * List retrieval/Q&A services. An omitted scene fans out to both scenes and merges.
 * @param client - the shared knowledge API client.
 * @param query - optional scene and fuzzy name filter.
 * @returns merged entries, the server-reported total, and the truncation flag.
 */
export async function listServices(
  client: KbClient,
  query: ListServicesQuery,
): Promise<ServiceList> {
  const scenes: ("chat" | "search")[] = query.scene ? [query.scene] : ["chat", "search"];
  const services: ServiceEntry[] = [];
  let total = 0;
  for (const scene of scenes) {
    const res = await client.postJson<ServiceListResponse>(KB_PATHS.serviceList, {
      agent_scene: scene,
      ...(query.nameFilter ? { agent_name: query.nameFilter } : {}),
      page_number: 1,
      page_size: MAX_PAGE_SIZE,
    });
    total += res.data?.total_count ?? 0;
    for (const row of res.data?.rows ?? []) {
      services.push({
        agent_id: row.agent_id ?? "",
        name: row.agent_name ?? "",
        scene: row.agent_scene ?? scene,
        status: row.agent_status ?? "",
        knowledge_bases: (row.pipeline_list ?? [])
          .map((p) => p.pipeline_name ?? p.pipeline_id ?? "")
          .filter(Boolean),
      });
    }
  }
  return { services, total, truncated: total > services.length };
}
  • Step 4: 运行确认通过 & Commit
pnpm vitest run packages/tool-bailian-kb/tests/services.test.ts
git add -A && git commit -m "feat: service discovery with scene merge and internalized pagination"

Task 7: createKbTools 工具工厂(三工具 + defaultAgentId 静态 schema + 错误附清单)

Files:

  • Create: packages/tool-bailian-kb/src/tools.ts

  • Test: packages/tool-bailian-kb/tests/tools.test.ts

  • Step 1: 写失败测试

packages/tool-bailian-kb/tests/tools.test.ts:

import { describe, expect, it, vi } from "vitest";
import { KbApiError, KbClient } from "../src/client.js";
import { createKbTools } from "../src/tools.js";

const EXEC = {} as never;

function toolsWith(postJson: unknown, postSse?: unknown, defaultAgentId?: string) {
  const client = { postJson, postSse, agentVersion: undefined } as unknown as KbClient;
  const list = createKbTools({ client, defaultAgentId, chatTimeoutMs: 1000 });
  const byName = Object.fromEntries(list.map((t) => [t.name, t]));
  return { byName, list };
}

const searchResponse = {
  request_id: "r1",
  data: {
    total: 3,
    nodes: [
      { score: 0.9, text: "A", metadata: { doc_name: "d1" } },
      { score: 0.8, text: "B", metadata: {} },
      { score: 0.7, text: "C", metadata: {} },
    ],
  },
};

describe("createKbTools", () => {
  it("registers exactly kb_service_list, kb_search, kb_chat", () => {
    const { list } = toolsWith(vi.fn());
    expect(list.map((t) => t.name).sort()).toEqual(["kb_chat", "kb_search", "kb_service_list"]);
  });

  it("kb_search truncates nodes client-side to top_k (default 5 documented, explicit here)", async () => {
    const postJson = vi.fn(async () => searchResponse);
    const { byName } = toolsWith(postJson);
    const out = (await byName.kb_search!.execute(
      { query: "q", agent_id: "aid-1", top_k: 2 },
      EXEC,
    )) as { chunks: unknown[] };
    expect(out.chunks).toHaveLength(2);
    const body = postJson.mock.calls[0]![1] as Record<string, unknown>;
    expect(body).not.toHaveProperty("top_k"); // the server API has no such parameter
  });

  it("agent_id is required without defaultAgentId and optional with one", () => {
    const withoutDefault = toolsWith(vi.fn()).byName.kb_search!;
    const withDefault = toolsWith(vi.fn(), undefined, "aid-fixed").byName.kb_search!;
    expect(
      (withoutDefault.parameters as Record<string, { required?: boolean }>).agent_id!.required,
    ).toBe(true);
    expect(
      (withDefault.parameters as Record<string, { required?: boolean }>).agent_id!.required,
    ).toBeUndefined();
  });

  it("kb_search falls back to defaultAgentId as an explicit resolve step", async () => {
    const postJson = vi.fn(async () => searchResponse);
    const { byName } = toolsWith(postJson, undefined, "aid-fixed");
    await byName.kb_search!.execute({ query: "q" }, EXEC);
    expect((postJson.mock.calls[0]![1] as Record<string, unknown>).agent_id).toBe("aid-fixed");
  });

  it("a 4xx failure appends the current service list to the error", async () => {
    const postJson = vi.fn(async (path: string) => {
      if (path === "/api/v1/indices/knowledge/search") throw new KbApiError("agent not found", 400);
      return {
        data: {
          total_count: 1,
          rows: [
            {
              agent_id: "aid-9",
              agent_name: "faq",
              agent_scene: "search",
              agent_status: "deployed",
            },
          ],
        },
      };
    });
    const { byName } = toolsWith(postJson);
    const err = await byName
      .kb_search!.execute({ query: "q", agent_id: "bad" }, EXEC)
      .catch((e: unknown) => e);
    expect((err as Error).message).toContain("aid-9");
  });

  it("kb_chat buffers the SSE stream into one answer", async () => {
    const sse =
      'data: {"output":{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]},"request_id":"r2"}\n\ndata: [DONE]\n\n';
    const postSse = vi.fn(async () => new Response(sse, { status: 200 }));
    const { byName } = toolsWith(vi.fn(), postSse);
    const out = (await byName.kb_chat!.execute({ message: "q", agent_id: "aid-1" }, EXEC)) as {
      answer: string;
    };
    expect(out.answer).toBe("hi");
  });
});
  • Step 2: 运行确认失败
pnpm vitest run packages/tool-bailian-kb/tests/tools.test.ts

Expected: FAIL(模块不存在)。

  • Step 3: 实现

packages/tool-bailian-kb/src/tools.ts:

/** The three model-facing knowledge tools. Schemas are static per deployment: a configured
 * defaultAgentId downgrades agent_id to optional at build time (never a runtime fallback chain). */

import { defineTool } from "@deepseek-ai/dsh-tools";
import type { SearchRequest, SearchResponse } from "./api-types.js";
import type { KbClient } from "./client.js";
import { KbApiError } from "./client.js";
import { consumeChatStream } from "./chat.js";
import { KB_PATHS } from "./endpoints.js";
import { listServices } from "./services.js";

/** Client-side chunk cap applied when the model omits top_k. */
const DEFAULT_TOP_K = 5;

export interface KbToolDeps {
  client: KbClient;
  defaultAgentId?: string;
  chatTimeoutMs: number;
}

/** Format one service list into the error-hint / result text form. */
function formatServices(
  services: { agent_id: string; name: string; scene: string; status: string }[],
): string {
  return services.map((s) => `${s.agent_id} (${s.name}, scene=${s.scene}, ${s.status})`).join("; ");
}

/**
 * Append the current service list to a client error so the model can correct
 * an invalid agent_id in one step. Auth failures (401/403) keep their own message.
 */
async function withServiceHint(client: KbClient, err: unknown): Promise<never> {
  if (
    err instanceof KbApiError &&
    err.status !== undefined &&
    err.status >= 400 &&
    err.status !== 401 &&
    err.status !== 403
  ) {
    try {
      const { services } = await listServices(client, {});
      if (services.length > 0) {
        throw new KbApiError(
          `${err.message}. Available services: ${formatServices(services)}`,
          err.status,
        );
      }
    } catch (hintErr) {
      if (hintErr instanceof KbApiError && hintErr.message.includes("Available services"))
        throw hintErr;
      // discovery is best-effort; fall through to the original error
    }
  }
  throw err;
}

/**
 * Build the three tool definitions over one shared client.
 * @param deps - client plus the deployment's explicit pinning and timeout choices.
 * @returns definitions ready for `ctx.tools.register()`.
 */
export function createKbTools(deps: KbToolDeps) {
  const { client, defaultAgentId, chatTimeoutMs } = deps;
  const agentIdParam = {
    type: "string" as const,
    ...(defaultAgentId === undefined ? { required: true as const } : {}),
    description:
      defaultAgentId === undefined
        ? "Retrieval/Q&A service id (find one via kb_service_list)."
        : `Retrieval/Q&A service id; omit to use this deployment's default service.`,
  };
  const resolveAgentId = (supplied: string | undefined): string => {
    const agentId = supplied ?? defaultAgentId;
    if (agentId === undefined)
      throw new Error("agent_id is required: discover services with kb_service_list");
    return agentId;
  };

  const serviceList = defineTool({
    name: "kb_service_list",
    description:
      "List the Bailian knowledge retrieval/Q&A services available in this workspace. " +
      "Each entry names the service id (agent_id) to pass to kb_search (scene=search) or kb_chat (scene=chat), " +
      "its bound knowledge bases, and its status (prefer deployed). " +
      "Omit scene to see both kinds; narrow large workspaces with name_filter.",
    parameters: {
      scene: {
        type: "string",
        enum: ["chat", "search"],
        description: "Only list services for this scene; omitted lists both.",
      },
      name_filter: { type: "string", description: "Fuzzy match on the service name." },
    },
    output: {
      schema: {
        type: "object",
        additionalProperties: false,
        properties: {
          services: {
            type: "array",
            required: true,
            items: {
              type: "object",
              additionalProperties: false,
              properties: {
                agent_id: { type: "string", required: true },
                name: { type: "string", required: true },
                scene: { type: "string", required: true },
                status: { type: "string", required: true },
                knowledge_bases: { type: "array", required: true, items: { type: "string" } },
              },
            },
          },
          total: { type: "integer", required: true },
          truncated: { type: "boolean", required: true },
        },
      },
      render: (_args, value) => [
        {
          type: "text",
          text:
            value.services.length === 0
              ? "No knowledge services found."
              : `${value.services.length} service(s): ${formatServices(value.services)}` +
                (value.truncated
                  ? ` — listed first ${value.services.length} of ${value.total}; narrow with name_filter.`
                  : ""),
        },
      ],
    },
    async execute(args) {
      return await listServices(client, {
        ...(args.scene === "chat" || args.scene === "search" ? { scene: args.scene } : {}),
        ...(args.name_filter ? { nameFilter: args.name_filter } : {}),
      });
    },
    presentCall: (args) => ({
      card: "generic",
      title: "List knowledge services",
      kind: "other",
      rawInput: args,
    }),
  });

  const search = defineTool({
    name: "kb_search",
    description:
      "Semantic search over a Bailian knowledge base. Returns raw knowledge chunks with scores and source " +
      "references for you to verify, cite, or combine with other context. Retrieval scope and strategy " +
      "(multi-KB weighting, routing, reranking) come from the service configuration. " +
      "top_k caps how many chunks return (client-side cut of the score-ranked results). " +
      "Use kb_chat instead when the user question can be answered by the knowledge base alone.",
    parameters: {
      query: { type: "string", required: true, description: "Search query text." },
      agent_id: agentIdParam,
      top_k: {
        type: "integer",
        description: `Maximum chunks to return; defaults to ${DEFAULT_TOP_K}.`,
      },
      images: {
        type: "array",
        items: { type: "string" },
        description: "Image URLs for multimodal retrieval.",
      },
    },
    output: {
      schema: {
        type: "object",
        additionalProperties: false,
        properties: {
          chunks: {
            type: "array",
            required: true,
            items: {
              type: "object",
              additionalProperties: false,
              properties: {
                text: { type: "string", required: true },
                score: { type: "number", required: true },
                doc_name: { type: "string" },
                doc_id: { type: "string" },
                title: { type: "string" },
              },
            },
          },
          total: { type: "integer", required: true },
        },
      },
      render: (_args, value) => [
        {
          type: "text",
          text:
            value.chunks.length === 0
              ? "No matching knowledge chunks."
              : value.chunks
                  .map(
                    (c, i) =>
                      `[${i + 1}] (score ${c.score.toFixed(2)}${c.doc_name ? `, ${c.doc_name}` : ""}) ${c.text}`,
                  )
                  .join("\n"),
        },
      ],
    },
    async execute(args) {
      const topK = args.top_k ?? DEFAULT_TOP_K;
      const body: SearchRequest = {
        query: args.query,
        agent_id: resolveAgentId(args.agent_id),
        ...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
        ...(args.images && args.images.length > 0 ? { images: args.images } : {}),
      };
      const res = await client
        .postJson<SearchResponse>(KB_PATHS.search, body)
        .catch((err) => withServiceHint(client, err));
      const nodes = (res.data?.nodes ?? []).slice(0, topK);
      return {
        chunks: nodes.map((n) => ({
          text: n.text,
          score: n.score,
          ...(typeof n.metadata?.doc_name === "string" ? { doc_name: n.metadata.doc_name } : {}),
          ...(typeof n.metadata?.doc_id === "string" ? { doc_id: n.metadata.doc_id } : {}),
          ...(typeof n.metadata?.title === "string" ? { title: n.metadata.title } : {}),
        })),
        total: res.data?.total ?? nodes.length,
      };
    },
    presentCall: (args) => ({
      card: "generic",
      title: "Search knowledge base",
      kind: "read",
      rawInput: args,
    }),
  });

  const chat = defineTool({
    name: "kb_chat",
    description:
      "Ask the knowledge base directly and get a complete, domain-tuned answer from a specialized RAG pipeline " +
      "(multi-round retrieval + reranking + grounded generation). For knowledge Q&A this typically outperforms " +
      "searching and synthesizing yourself when the question can be answered by the knowledge base alone; " +
      "use kb_search instead when you need raw chunks to verify, cite, or combine with other work. " +
      "The pipeline runs an internal analysis/retrieval loop and may take a few minutes.",
    parameters: {
      message: { type: "string", required: true, description: "The question to ask." },
      agent_id: agentIdParam,
    },
    output: {
      schema: {
        type: "object",
        additionalProperties: false,
        properties: {
          answer: { type: "string", required: true },
          request_id: { type: "string" },
        },
      },
      render: (_args, value) => [
        { type: "text", text: value.answer.length === 0 ? "(empty answer)" : value.answer },
      ],
    },
    async execute(args) {
      const body = {
        input: { messages: [{ role: "user" as const, content: args.message }] },
        parameters: {
          agent_options: {
            agent_id: resolveAgentId(args.agent_id),
            ...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
          },
        },
        stream: true as const,
      };
      let res: Response;
      try {
        res = await client.postSse(KB_PATHS.chat, body, AbortSignal.timeout(chatTimeoutMs));
      } catch (err) {
        if (err instanceof Error && err.name === "TimeoutError") {
          throw new Error(
            `knowledge chat timed out after ${chatTimeoutMs}ms; the pipeline runs a multi-round retrieval loop ` +
              "and long questions can exceed the deployment timeout. Retry, or use kb_search for raw chunks instead.",
          );
        }
        return await withServiceHint(client, err);
      }
      const { answer, requestId } = await consumeChatStream(res);
      return { answer, ...(requestId ? { request_id: requestId } : {}) };
    },
    presentCall: (args) => ({
      card: "generic",
      title: "Ask knowledge base (may take a few minutes)",
      kind: "read",
      rawInput: args,
    }),
  });

  return [serviceList, search, chat];
}

注意:defineTool 的参数 schema 结构以 @deepseek-ai/dsh-tools 实际类型为准(模板 tool-todo);若 enum/items 字段名有出入,按其 ParameterSchemaSpec 类型修正,测试断言随之调整——不得为绕过类型错误引入 any。

  • Step 4: 运行确认通过 & Commit
pnpm vitest run packages/tool-bailian-kb/tests/tools.test.ts
git add -A && git commit -m "feat: kb_service_list, kb_search, kb_chat tool factory"

Task 8: SKILL.md、skill 注册与插件入口装配

Files:

  • Create: packages/tool-bailian-kb/skills/bailian-kb-management/SKILL.md、packages/tool-bailian-kb/src/skill.ts

  • Modify: packages/tool-bailian-kb/src/index.ts(追加 apply)

  • Step 1: 写 SKILL.md

packages/tool-bailian-kb/skills/bailian-kb-management/SKILL.md:

---
name: bailian-kb-management
description: 管理阿里云百炼知识库(建库、上传文档、部署检索服务、Chunk 运维)。当用户要创建/更新/删除知识库、上传或导入文档、部署检索服务、管理数据中心文件时使用 kscli。检索与问答不走本 skill——用原生工具 kb_search / kb_chat。
---

# 百炼知识库管理(kscli)

检索面与管理面的分工:**查知识用 `kb_search`(取证据)/ `kb_chat`(成品问答)原生工具;本 skill 只覆盖管理长尾**——知识库全生命周期、文档、检索服务、Chunk、数据中心。

## 前置检查

1. `kscli --version` —— 未安装则运行 `npm install -g knowledge-studio-cli`(需 Node.js ≥ 18.17);安装失败时把错误原样报告给用户,不要静默跳过。
2. 鉴权:需要 `DASHSCOPE_API_KEY`(环境变量,或 `kscli config set --key api_key --value sk-xxx`)。
3. workspace 解析优先级:`--workspace-id` 参数 > 环境变量 `BAILIAN_WORKSPACE_ID` > `kscli config set --key workspace_id --value ws-xxx`。

## 常用工作流:建库到可检索

```bash
kscli kb create --name "my-kb" --embedding-model text-embedding-v3   # 1. 建库
kscli doc upload --kb-id <kb-id> --file ./docs.pdf                    # 2. 上传本地文档
kscli doc status --kb-id <kb-id> --doc-id <doc-id>                    # 3. 轮询至 COMPLETED
kscli service create ... && kscli service deploy ...                  # 4. 建/部署检索服务 → 得到 agent_id
```

部署完成后用 `kb_service_list` 确认服务可见,再用 `kb_search --agent-id` 验证检索。

## 命令组速查

`kb`(list/info/create/update/delete/stats)· `doc`(list/upload/status/delete/tag/import-oss)· `service`(list/get/create/update/deploy/delete/copy)· `chunk`(add/list/update/delete)· `file` / `collection` / `category`(数据中心)。全部命令支持 `--output json`(结构化输出)、`--dry-run`(预览请求)、`--quiet`。完整手册:https://github.com/modelstudioai/cli/blob/main/docs/knowledge-cli-guide.md

## 最佳实践

- 用户反复使用同一检索服务时,建议其把 agent_id 写入项目指令(如 AGENTS.md)或让 agent 记住,后续 kb_search / kb_chat 直接携带。
- 服务有 draft/deployed 两种状态:只有 deployed 可被默认版本调用;draft 调试用 `--agent-version beta`。
  • Step 2: 实现 skill 注册

packages/tool-bailian-kb/src/skill.ts:

/** Runtime skill registration: the packaged kscli-management SKILL.md joins the catalog when a skills registry is composed. */

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { Context } from "@deepseek-ai/cordis";
// Type-only: resolves ctx.skills for the optional inject below.
import type {} from "@deepseek-ai/dsh-skill";

const SKILL_DIR = fileURLToPath(new URL("../skills/bailian-kb-management/", import.meta.url));

/**
 * Register the management skill when the skills registry is composed; headless
 * assemblies without the seam stay unaffected.
 * @param ctx - the plugin context.
 */
export function registerSkill(ctx: Context): void {
  ctx.inject(["skills"], (skillCtx) => {
    const content = readFileSync(`${SKILL_DIR}SKILL.md`, "utf8");
    skillCtx.skills.register({
      name: "bailian-kb-management",
      description:
        "Manage Bailian knowledge bases with the kscli CLI: create/update KBs, upload documents, deploy " +
        "retrieval services, and maintain chunks. Retrieval itself uses the native kb_search/kb_chat tools.",
      content,
      source: "bundled",
      resourceBase: { kind: "directory", path: SKILL_DIR },
    });
  });
}

(lib/skill.js 相对 ../skills/ 解析到包根 skills/,与 files 白名单一致。frontmatter 保留在 content 内无害;若 dsh 渲染出现重复描述,实现时剥离 frontmatter 再注册。)

  • Step 3: 装配 apply

在 packages/tool-bailian-kb/src/index.ts 追加(Config 声明保持不变):

import type { Context } from "@deepseek-ai/cordis";
import { credentialRef } from "@deepseek-ai/dsh-credentials";
import { KbClient } from "./client.js";
import { registerSkill } from "./skill.js";
import { createKbTools } from "./tools.js";

/**
 * Register the three knowledge tools over one shared client, plus the
 * management skill when a skills registry is composed.
 * @param ctx - registrant context carrying tools and credentials.
 * @param config - deployment's workspace, host, pinning, and timeout choices.
 */
export function apply(ctx: Context, config: Config): void {
  const client = new KbClient({
    workspaceId: config.workspaceId,
    endpointHost: config.endpointHost,
    ...(config.agentVersion ? { agentVersion: config.agentVersion } : {}),
    resolveApiKey: async () => {
      const resolved = await ctx.credentials.resolve(credentialRef("DASHSCOPE_API_KEY"));
      if (!resolved) {
        throw new Error(
          "DASHSCOPE_API_KEY is not configured. Set it in ~/.dsh/.env or .credentials.yaml " +
            "(create a key at https://bailian.console.aliyun.com/?tab=app#/api-key).",
        );
      }
      return resolved.value;
    },
  });
  for (const tool of createKbTools({
    client,
    ...(config.defaultAgentId ? { defaultAgentId: config.defaultAgentId } : {}),
    chatTimeoutMs: config.chatTimeoutMs,
  })) {
    ctx.tools.register(tool);
  }
  registerSkill(ctx);
}
  • Step 4: 全量测试 + 构建验证 & Commit
pnpm run test && pnpm run typecheck && pnpm run build
git add -A && git commit -m "feat: plugin apply wiring with credential-backed client and skill registration"

Expected: 测试全 PASS;packages/tool-bailian-kb/lib/ 产出 index.js 等。

Task 9: bundle 分发包

Files:

  • Create: packages/bundle/package.json、packages/bundle/cordis.patch.yml

  • Step 1: 写 bundle 包

packages/bundle/package.json:

{
  "name": "bailian-kb-bundle",
  "version": "0.1.0",
  "description": "Installable dsh bundle for Bailian knowledge-base tools: kb_service_list, kb_search, kb_chat plus the kscli management skill.",
  "type": "module",
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
  "exports": {
    "./cordis.patch.yml": "./cordis.patch.yml",
    "./package.json": "./package.json"
  },
  "files": ["cordis.patch.yml"],
  "dependencies": { "dsh-tool-bailian-kb": "workspace:^" }
}

(发布时 workspace:^ 由 pnpm publish 自动替换为版本号;git spec 安装场景改为固定版本依赖——实现时二选一并在 README 记录。)

packages/bundle/cordis.patch.yml:

# bailian-kb-bundle: inserts the Bailian knowledge-base consumer over dsh-base.
# workspaceId reads BAILIAN_WORKSPACE_ID from the environment (~/.dsh/.env) so a
# user patch is only needed to pin defaultAgentId or override the host/timeout.
# An id-targeted user patch replaces this whole config: restate workspaceId too.

- insert:
    - id: tool-bailian-kb
      name: dsh-tool-bailian-kb
      config:
        workspaceId: !!js process.env.BAILIAN_WORKSPACE_ID
  • Step 2: Commit
git add -A && git commit -m "feat: installable dsh bundle package"

Task 10: README 文档

Files:

  • Create: README.md(根)、packages/tool-bailian-kb/README.md、packages/bundle/README.md

  • Step 1: 写三份 README

根 README.md:项目一句话定位、两包结构表、安装(dsh plugin --profile web add bailian-kb-bundle)、配置(BAILIAN_WORKSPACE_ID + DASHSCOPE_API_KEY 写入 ~/.dsh/.env)、验证(dsh --profile web --dump-config)、开发命令(install/test/typecheck/build)。

packages/tool-bailian-kb/README.md:Config 字段表(五个字段与默认值,含 defaultAgentId 的静态 schema 语义)、三个工具的参数/返回摘要、错误语义(4xx 附服务清单、凭证缺失指引、chat 超时)、Known Limitations(chat 执行期无进展显示——进展流式见 spec 附录 A;服务清单单页 100 上限靠 name_filter 收窄)。

packages/bundle/README.md:patch row 说明、用户覆盖示例(id-targeted patch 需 restate 整个 config)、卸载方式。

  • Step 2: Commit
git add -A && git commit -m "docs: package and repository READMEs"

Task 11: dsh 本地集成验收(手动 smoke)

前置:本机可运行 dsh(deepseek-harness checkout 或全局安装);~/.dsh/.env 写入真实 DASHSCOPE_API_KEY 与 BAILIAN_WORKSPACE_ID。

  • Step 1: 安装进本地 profile
cd /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle && pnpm run build
cd /Users/zeyufeng/Documents/Code/workspace/deepseek-harness
pnpm dsh plugin --profile headless add /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle/packages/bundle

Expected: pnpm 安装成功,输出提示 bundle 加入 dsh.profile.bundles。

  • Step 2: 验证组合树
pnpm dsh --profile headless --dump-config | grep -A 4 tool-bailian-kb

Expected: 能看到 tool-bailian-kb row 及解析后的 workspaceId。

  • Step 3: 真实任务 smoke(需要 DEEPSEEK_API_KEY)
pnpm dsh --profile headless "列出可用的知识库检索服务"
pnpm dsh --profile headless "用知识库检索:<你知识库里确定存在的主题>,给出来源"

Expected: 模型调用 kb_service_list / kb_search 并给出带来源的回答。将实际 transcript 记录到验收笔记。

  • Step 4: 负例验证(fail loud)
# 临时移除 BAILIAN_WORKSPACE_ID 后:
pnpm dsh --profile headless --dump-config

Expected: 加载期报错指向 workspaceId 缺失(schemastery required),而不是静默跳过。验证后恢复环境变量。

  • Step 5: 打 tag
cd /Users/zeyufeng/Documents/Code/workspace/bailian-kb-bundle && git tag v0.1.0

与 spec 的偏差记录

  • 测试策略降级(spec §10):spec 要求 mock HTTP fixture 的 keyless snapshot 与真实 API e2e;首版以单元测试(Task 2-7)+ 手动集成验收(Task 11)覆盖,snapshot/e2e 基建依赖 dsh snapshot harness 对 out-of-tree bundle 的支持情况,作为 v0.2 跟进项记入根 README 的 Known Limitations。其余均与 2026-08-15 spec 最终版一致。

明确不在本计划内(spec §2)

retrieve 工具、MCP 通道、chat 进展流式 UI、run_in_background、skills 生态独立分发、snapshot/e2e 测试基建(依赖 dsh snapshot harness 对 out-of-tree bundle 的支持情况,首版以单元测试 + 手动集成验收覆盖,作为 v0.2 跟进项记录在根 README 的 Known Limitations)。