diff --git a/packages/tool-bailian-kb/package.json b/packages/tool-bailian-kb/package.json
index 9f6591e..935d3d0 100644
--- a/packages/tool-bailian-kb/package.json
+++ b/packages/tool-bailian-kb/package.json
@@ -1,6 +1,6 @@
{
"name": "@ali/bailian-kb-dsh",
- "version": "0.1.16",
+ "version": "0.1.18",
"description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.",
"type": "module",
"main": "lib/index.js",
diff --git a/packages/tool-bailian-kb/src/index.ts b/packages/tool-bailian-kb/src/index.ts
index 12bd27a..cf59a98 100644
--- a/packages/tool-bailian-kb/src/index.ts
+++ b/packages/tool-bailian-kb/src/index.ts
@@ -324,6 +324,23 @@ export function apply(ctx: Context, config: Config): void {
}
registerSkill(ctx)
+ // A management command that changes the service inventory invalidates the
+ // cache immediately, so the next session sees the new service instead of
+ // waiting out the TTL. `tools/result` is observe-only (it returns undefined and
+ // sits after the pipeline), so listening here cannot affect tool execution.
+ //
+ // The command string is matched inside the serialized arguments rather than
+ // against a specific tool name: the agent may run `bl` through bash, a
+ // terminal tool, or a run_code program. A loose match is deliberate — a false
+ // positive costs one list request, while a miss falls back to the TTL.
+ ctx.on('tools/result', (_exec, result) => {
+ if (result.isError) return
+ const args = JSON.stringify((_exec as { arguments?: unknown }).arguments ?? '')
+ if (!/bl\s+knowledge\s+service\s+(create|deploy|delete|copy)/.test(args)) return
+ serviceCache.invalidate()
+ void serviceCache.refresh()
+ })
+
// The service catalog rides an `agent/pre-step` context message rather than the
// tool descriptions: descriptions freeze at plugin load, and a plugin loads
// once per process, so in a long-running host a service created elsewhere
@@ -423,6 +440,38 @@ export function apply(ctx: Context, config: Config): void {
},
}), 'tool-bailian-kb: settings bridge route')
+ // Service cache bridge: the panel's only window into cache freshness.
+ // GET returns the diagnostic snapshot plus the pickable services; POST
+ // forces a refresh and returns the same shape, so the numbers the developer
+ // sees update in place.
+ wctx.effect(() => wctx.webServer.register({
+ kind: 'exact',
+ path: '/bailian-kb/services',
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'POST') {
+ sendJson(res, 405, { error: 'use GET or POST' })
+ return
+ }
+ const workspaceId = await resolveWorkspaceIdOrUndefined()
+ if (workspaceId === undefined) {
+ sendJson(res, 200, { configured: false })
+ return
+ }
+ if (req.method === 'POST') {
+ // Force a fetch regardless of TTL: the button exists precisely for the
+ // case where the developer believes the cache is wrong.
+ serviceCache.invalidate()
+ await serviceCache.refresh()
+ }
+ sendJson(res, 200, {
+ configured: true,
+ status: serviceCache.status(workspaceId),
+ search: serviceCache.entriesFor(workspaceId, 'search'),
+ chat: serviceCache.entriesFor(workspaceId, 'chat'),
+ })
+ },
+ }), 'tool-bailian-kb: service cache bridge route')
+
// Autofill bridge: fetch credentials by signing in to the Bailian console
// (panel button). `login` drives the console's callback protocol on the
// host and persists what comes back — the plain key never rides the wire
diff --git a/packages/tool-bailian-kb/src/service-cache.ts b/packages/tool-bailian-kb/src/service-cache.ts
index 1b53c1b..114bb3d 100644
--- a/packages/tool-bailian-kb/src/service-cache.ts
+++ b/packages/tool-bailian-kb/src/service-cache.ts
@@ -32,6 +32,18 @@ const CACHE_VERSION = 1
/** Refresh interval. Evaluated per `agent/pre-step`, so a short window genuinely takes effect. */
export const CACHE_TTL_MS = 30 * 60 * 1000
+/**
+ * Refresh interval applied when the cached list is EMPTY.
+ *
+ * An empty list is almost never a settled fact — it is the intermediate state of
+ * a workspace being set up. Caching that negative result for the full TTL breaks
+ * the standard first-run path: configure the plugin against a fresh workspace (0
+ * services) → create a knowledge base and a service → and then wait up to half an
+ * hour before the catalog appears. Re-asking every minute while the answer is
+ * "nothing yet" has a bounded cost and removes that trap.
+ */
+export const EMPTY_CACHE_TTL_MS = 60 * 1000
+
/** The stored document. */
export interface ServiceCacheDocument {
version: number
@@ -109,6 +121,19 @@ export function writeServiceCache(path: string, doc: ServiceCacheDocument): void
renameSync(temp, path)
}
+/** What the settings panel shows about the cache; see {@link ServiceCache.status}. */
+export interface ServiceCacheStatus {
+ workspaceId: string
+ /** Epoch millis of the last successful fetch; absent when nothing is cached. */
+ fetchedAt?: number
+ searchCount: number
+ chatCount: number
+ /** Server-reported total, which exceeds the counts above when the fetch was capped. */
+ total: number
+ truncated: boolean
+ stale: boolean
+}
+
export interface ServiceCacheOptions {
client: KbClient
/** Resolves the current workspace id; a failure means "not configured yet". */
@@ -158,13 +183,16 @@ export class ServiceCache {
}
/**
- * Whether the cached document is missing or older than the TTL.
+ * Whether the cached document is missing or older than its TTL.
+ * An empty list expires on the much shorter {@link EMPTY_CACHE_TTL_MS}.
* @param workspaceId - the workspace being served.
* @returns true when a refresh is due.
*/
isStale(workspaceId: string): boolean {
const doc = this.peek(workspaceId)
- return doc === undefined || this.now - doc.fetchedAt >= CACHE_TTL_MS
+ if (doc === undefined) return true
+ const ttl = doc.entries.length === 0 ? EMPTY_CACHE_TTL_MS : CACHE_TTL_MS
+ return this.now - doc.fetchedAt >= ttl
}
/** Drop the in-memory view and force the next `peek` to re-read from disk. */
@@ -173,6 +201,48 @@ export class ServiceCache {
this.loadedFor = undefined
}
+ /**
+ * A diagnostic snapshot for the settings panel.
+ *
+ * The panel exists because this cache's staleness is otherwise invisible: a
+ * developer whose agent silently stops retrieving cannot tell an empty
+ * workspace from a stale list without reading the JSON file. `fetchedAt` plus
+ * the per-scene counts answer that in one glance.
+ * @param workspaceId - the workspace being served.
+ * @returns the snapshot; `fetchedAt` is undefined when nothing is cached.
+ */
+ status(workspaceId: string): ServiceCacheStatus {
+ const doc = this.peek(workspaceId)
+ if (doc === undefined) {
+ return { workspaceId, searchCount: 0, chatCount: 0, total: 0, truncated: false, stale: true }
+ }
+ return {
+ workspaceId,
+ fetchedAt: doc.fetchedAt,
+ searchCount: doc.entries.filter(entry => entry.scene === 'search').length,
+ chatCount: doc.entries.filter(entry => entry.scene === 'chat').length,
+ total: doc.total,
+ truncated: doc.truncated,
+ stale: this.isStale(workspaceId),
+ }
+ }
+
+ /**
+ * The cached entries of one scene, most recently modified first.
+ * Backs the panel's service picker, which exists so a default service can be
+ * chosen by name instead of by pasting a 36-character hex id.
+ * @param workspaceId - the workspace being served.
+ * @param scene - `search` or `chat`.
+ * @returns the entries, newest first.
+ */
+ entriesFor(workspaceId: string, scene: ServiceEntry['scene']): ServiceEntry[] {
+ const doc = this.peek(workspaceId)
+ if (doc === undefined) return []
+ return doc.entries
+ .filter(entry => entry.scene === scene)
+ .sort((left, right) => (right.modify_time ?? '').localeCompare(left.modify_time ?? ''))
+ }
+
/**
* Fetch and store the current service list.
* Never rejects: failures are warned and leave the previous document in place.
diff --git a/packages/tool-bailian-kb/src/web/BailianCard.tsx b/packages/tool-bailian-kb/src/web/BailianCard.tsx
index 8b87562..d29b2e3 100644
--- a/packages/tool-bailian-kb/src/web/BailianCard.tsx
+++ b/packages/tool-bailian-kb/src/web/BailianCard.tsx
@@ -46,12 +46,17 @@ interface FieldView {
getUrl?: string
}
-/** The controls, in page order. */
+/**
+ * The controls, in page order.
+ *
+ * The two default-service ids are NOT here: they render as pickers inside the
+ * advanced section instead, driven by the cached service list. A free-text id
+ * field beside a picker for the same setting is the same value twice, and the
+ * pair drifts the moment one of them writes.
+ */
const FIELDS: readonly FieldView[] = [
{ key: 'DASHSCOPE_API_KEY', labelKey: 'apiKey', getKey: 'apiKeyGet', hintKey: 'apiKeyHint', fallbackHintKey: 'apiKeyHint', setKey: 'apiKeySet', unsetKey: 'apiKeyUnset', secret: true, advanced: true, getUrl: BAILIAN_CONSOLE_API_KEY_URL },
{ key: 'BAILIAN_WORKSPACE_ID', labelKey: 'workspaceId', getKey: 'workspaceIdGet', hintKey: 'workspaceIdHint', fallbackHintKey: 'workspaceIdHintFallback', setKey: 'workspaceIdSet', unsetKey: 'workspaceIdUnset', secret: false, advanced: true, getUrl: BAILIAN_CONSOLE_API_KEY_URL },
- { key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID', labelKey: 'retrieveAgentId', hintKey: 'retrieveAgentIdHint', fallbackHintKey: 'retrieveAgentIdHintFallback', setKey: 'retrieveAgentIdSet', unsetKey: 'retrieveAgentIdUnset', secret: false, advanced: true },
- { key: 'BAILIAN_DEFAULT_CHAT_AGENT_ID', labelKey: 'chatAgentId', hintKey: 'chatAgentIdHint', fallbackHintKey: 'chatAgentIdHintFallback', setKey: 'chatAgentIdSet', unsetKey: 'chatAgentIdUnset', secret: false, advanced: true },
]
const ADVANCED_FIELDS = FIELDS.filter(field => field.advanced)
@@ -167,6 +172,114 @@ export function BailianCard(props: BailianCardProps) {
)
}
+ /**
+ * One scene's default-service picker — the sole control for that setting.
+ *
+ * The cached list is the menu, but a value already pinned outside this list
+ * (the fetch is capped, so an older service can be absent) is prepended as its
+ * own option: dropping it would make the panel silently forget a live setting.
+ */
+ function renderPicker(scene: 'search' | 'chat') {
+ const cache = state.cache
+ const entries = scene === 'search' ? cache.search : cache.chat
+ const pinned = scene === 'search'
+ ? state.settings.values.defaultRetrieveAgentId
+ : state.settings.values.defaultChatAgentId
+ const isPinned = pinned !== undefined && pinned !== ''
+ const pinnedIsListed = isPinned && entries.some(entry => entry.agent_id === pinned)
+ return (
+
+ )
+ }
+
+ /**
+ * The service-cache diagnostics: last fetch, per-scene counts, refresh.
+ *
+ * This stays OUTSIDE the advanced fold on purpose. It is the answer to "why did
+ * the agent stop retrieving" — an empty workspace and a stale list look
+ * identical from the outside, and before this the only way to tell them apart
+ * was reading the cache JSON off disk.
+ */
+ function renderCacheStatus() {
+ const cache = state.cache
+ if (cache.status === 'loading') return