feat(dsh-plugin-browserskill): reveal tool schemas only after skill invocation

Progressive disclosure, final stage: with lazyTools on (default), the eleven
browser_* tool schemas stay out of the system prompt — the browser-skill
catalog entry is the sole advertisement. One successful skill invocation
(model tool call via tools/result, or a /browser-skill user gesture via the
session append feed) registers the suite for the process lifetime, idempotent
on repeats; session resume is covered by scanning durable events for a past
tool/call+tool/result pair (or skill-invocation message) on session entry and
at apply time. lazyTools: false keeps the legacy always-on registration.

Investigation (recorded in the ticket): tools/result carries normalized
name/arguments with an isError discriminant; dsh 0.1 has no official global
visibility switch (ctx.tools.restrict is agent-scoped and throws from a plain
context), making conditional registration the intended pattern; per-step
assembly picks the new suite up on the next step, and visibility changes are
first-class inputs of tool-skill's catalog digest. registerTools now returns
the combined disposer via a prototype-preserving tracking overlay; the skill
prelude tells the model the browser_* tools unlock on skill load.

Tests: +8 (hidden-before/reveal/idempotent/gesture/history hit+miss/dispose/
scanner pairing/two-state apply wiring). 124/124 green.
This commit is contained in:
BB-fat
2026-08-14 16:43:51 +00:00
parent 4d1dcd122f
commit ce11325de2
7 changed files with 423 additions and 6 deletions
+17 -2
View File
@@ -12,6 +12,7 @@
import type { Context } from "@deepseek-ai/cordis";
import Schema from "@deepseek-ai/schemastery";
import { armLazyTools } from "./lazy-tools";
import { ObservationService } from "./observation";
import { registerObservationRoutes } from "./observation-http";
import { KeyedExecutor } from "./queue";
@@ -43,6 +44,12 @@ export const Config = Schema.object({
idleIntervalMs: Schema.number()
.default(8000)
.description("Thumbnail refresh cadence for idle sessions; also the recent-activity window."),
lazyTools: Schema.boolean()
.default(true)
.description(
"Reveal the browser_* tools only after the browser-skill skill is invoked (default true); " +
"false registers the full suite at load.",
),
});
export type Config = PluginConfig;
@@ -64,6 +71,7 @@ export function apply(
observationEnabled: config.observationEnabled ?? true,
thumbnailIntervalMs: config.thumbnailIntervalMs ?? 1500,
idleIntervalMs: config.idleIntervalMs ?? 8000,
lazyTools: config.lazyTools ?? true,
};
const runner = options.runnerFactory?.(resolved.bskPath) ?? createBskRunner(resolved.bskPath);
const registry = new SessionRegistry(resolved.maxSessions);
@@ -80,11 +88,17 @@ export function apply(
},
});
registerTools({ ctx, runner, registry, config: resolved, observation, queue });
// Progressive disclosure of the BSK agent skill (catalog entry resident,
// body on demand) through the official skill seam; silent no-op when the
// composition lacks it.
// composition lacks it. With lazyTools on, the skill entry is initially the
// ONLY model-visible advertisement — the tool suite reveals itself on a
// successful skill invocation (or a session whose history already has one).
const unregisterSkill = registerBskSkill(ctx);
const removeSuite = resolved.lazyTools
? armLazyTools(ctx, () =>
registerTools({ ctx, runner, registry, config: resolved, observation, queue }),
)
: registerTools({ ctx, runner, registry, config: resolved, observation, queue });
// Route registration rides ctx.inject: the webServer service may be provided
// AFTER this plugin loads, and in headless compositions it never appears (the
// callback simply never runs, leaving the rest of the plugin unaffected).
@@ -115,6 +129,7 @@ export function apply(
// are swallowed so one stale handle cannot abort the rest.
ctx.effect(() => {
return () => {
removeSuite();
unregisterSkill();
removeRoutes();
observation.dispose();
@@ -0,0 +1,144 @@
/**
* Lazy tool-schema injection ("progressive disclosure", final stage): with
* `lazyTools` on (the default), the 11 browser_* tool schemas stay OUT of the
* system prompt until the `browser-skill` skill has actually been invoked —
* the skill catalog entry is the only advertisement. One successful
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals
* the whole suite for the rest of the process lifetime; repeated invocations
* are idempotent no-ops. Session resume is covered by scanning durable
* session events for a past successful invocation (tool/call + tool/result
* pair, or a skill-invocation sourced message) when a session is entered.
*
* Verified against dsh 0.1 (recorded in the PR ticket):
* - `tools/result(exec, result)`: exec carries normalized `name`/`arguments`,
* result is discriminated by `isError` — the model-invocation trigger.
* - There is NO official global tool-visibility switch (`ctx.tools.restrict`
* is agent-scoped and throws from a plain host context), so conditional
* registration + this hook is the intended pattern; the registry supports
* mid-flight register/dispose with an unfiltered `tools/change` notice, and
* tool-skill's per-step catalog digest treats visibility changes as a
* first-class cache-invalidation input — the suite simply appears in the
* NEXT step's assembly.
* - Durable events: `tool/call` {callId, name, arguments(JSON string)} pairs
* with `tool/result` {message: {callId, isError}} — the history signal.
*/
import type { Context } from "@deepseek-ai/cordis";
import { BSK_SKILL_NAME } from "./skill-content.generated";
/** Structural view of the pieces of the session seam we consume. */
interface SessionLike {
events: readonly SessionEventLike[];
}
interface SessionEventLike {
type: string;
data?: unknown;
}
interface SessionsLike {
list(): SessionLike[];
}
interface ToolResultExecutionLike {
name: string;
arguments?: unknown;
}
/** Parse a tool arguments payload that may be normalized (object) or raw JSON. */
function skillNameOf(args: unknown): string | undefined {
if (typeof args === "string") {
try {
return skillNameOf(JSON.parse(args));
} catch {
return undefined;
}
}
if (typeof args === "object" && args !== null && "name" in args) {
const name = (args as { name?: unknown }).name;
return typeof name === "string" ? name : undefined;
}
return undefined;
}
function isSkillInvocationMessage(data: unknown): boolean {
if (typeof data !== "object" || data === null) return false;
const source = (data as { source?: unknown }).source;
if (typeof source !== "object" || source === null) return false;
const { kind, name } = source as { kind?: unknown; name?: unknown };
return kind === "skill-invocation" && name === BSK_SKILL_NAME;
}
/**
* A durable log proves the skill was successfully invoked when a successful
* `tool/result` pairs a `tool/call` for skill/browser-skill — or when a
* `/browser-skill` user gesture landed as a skill-invocation message.
*/
export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]): boolean {
const skillCallIds = new Set<unknown>();
for (const event of events) {
if (event.type !== "tool/call") continue;
const data = event.data as { callId?: unknown; name?: unknown; arguments?: unknown };
if (data?.name === "skill" && skillNameOf(data.arguments) === BSK_SKILL_NAME) {
skillCallIds.add(data.callId);
}
}
for (const event of events) {
if (event.type === "tool/result") {
const message = (event.data as { message?: unknown })?.message;
if (typeof message !== "object" || message === null) continue;
const { callId, isError } = message as { callId?: unknown; isError?: unknown };
if (isError === false && skillCallIds.has(callId)) return true;
}
if (isSkillInvocationMessage(event.data)) return true;
}
return false;
}
/**
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when
* the reveal already happened — the tool suite itself.
* @param registerSuite - registers the 11 browser_* tools, returns their disposer.
*/
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
let suiteDisposer: (() => void) | undefined;
const disposers: (() => void)[] = [];
const ensureSuite = (): void => {
if (suiteDisposer !== undefined) return;
suiteDisposer = registerSuite();
};
// Live trigger: a successful model invocation of skill/browser-skill.
const onToolResult = (exec: ToolResultExecutionLike, result: { isError: boolean }): void => {
if (result.isError) return;
if (exec.name !== "skill") return;
if (skillNameOf(exec.arguments) === BSK_SKILL_NAME) ensureSuite();
};
disposers.push(ctx.on("tools/result" as never, onToolResult as never));
const scanSession = (session: SessionLike): void => {
try {
if (hasSuccessfulSkillInvocation(session.events)) ensureSuite();
} catch {
// A session object that cannot be read must never break plugin startup.
}
};
// Live gesture/append feed: covers /browser-skill user gestures (no tool
// call happens on that path) landing as skill-invocation messages.
const onSessionEvent = (_session: SessionLike, event: SessionEventLike): void => {
if (isSkillInvocationMessage(event?.data)) ensureSuite();
};
disposers.push(ctx.on("session/event" as never, onSessionEvent as never));
// History restore: sessions entered from now on, plus any already live.
const onSessionCreated = (session: SessionLike): void => scanSession(session);
disposers.push(ctx.on("session/created" as never, onSessionCreated as never));
const sessions = ctx.get("sessions") as SessionsLike | null | undefined;
if (sessions != null && typeof sessions.list === "function") {
for (const session of sessions.list()) scanSession(session);
}
return () => {
for (const dispose of disposers.splice(0)) dispose();
suiteDisposer?.();
};
}
+26 -3
View File
@@ -26,6 +26,13 @@ export interface PluginConfig {
observationEnabled: boolean;
thumbnailIntervalMs: number;
idleIntervalMs: number;
/**
* Lazy tool-schema injection: when true (default), the browser_* tools are
* registered only after the `browser-skill` skill has been successfully
* invoked (skill catalog entry advertises alone until then); when false,
* the suite is registered at apply time (legacy always-on behavior).
*/
lazyTools: boolean;
}
export interface ToolDeps {
@@ -136,9 +143,22 @@ function abortError(): Error {
return error;
}
/** Register the full browser tool suite. */
export function registerTools(deps: ToolDeps): void {
const { ctx, registry } = deps;
/** Register the full browser tool suite; returns the combined unregister disposer. */
export function registerTools(deps: ToolDeps): () => void {
// Track every registration through a prototype-preserving overlay so the
// lazy-injection path can dispose the whole suite in one call.
const disposers: (() => void)[] = [];
const ctx = Object.create(deps.ctx) as ToolDeps["ctx"];
const toolsOverlay = Object.create(deps.ctx.tools) as ToolDeps["ctx"]["tools"];
const originalRegister = deps.ctx.tools.register;
toolsOverlay.register = (definition: Parameters<typeof originalRegister>[0]) => {
const dispose = originalRegister.call(deps.ctx.tools, definition);
// Test doubles occasionally return the registry map instead of a disposer.
if (typeof dispose === "function") disposers.push(dispose);
return dispose;
};
ctx.tools = toolsOverlay;
const { registry } = deps;
ctx.tools.register(
defineTool({
@@ -934,4 +954,7 @@ export function registerTools(deps: ToolDeps): void {
presentResult: presentTerminalResult,
}),
);
return () => {
for (const dispose of disposers.splice(0)) dispose();
};
}