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
@@ -5,6 +5,11 @@ through this plugin's `browser_*` tools. Each tool spawns `bsk <cmd> --json` —
same engine the canonical CLI skill documents — so the workflow, judgment rules, and same engine the canonical CLI skill documents — so the workflow, judgment rules, and
safety constraints are identical. safety constraints are identical.
> **Tool availability**: invoking this skill (which you just did) makes the plugin's
> `browser_*` tools available for the rest of the session — they are injected on
> successful skill load. Until then they are deliberately hidden, so do not attempt
> a browser action before loading this skill.
## Tool ↔ CLI map ## Tool ↔ CLI map
The tools below are thin, structured wrappers over the bsk CLI verbs of the same The tools below are thin, structured wrappers over the bsk CLI verbs of the same
+17 -2
View File
@@ -12,6 +12,7 @@
import type { Context } from "@deepseek-ai/cordis"; import type { Context } from "@deepseek-ai/cordis";
import Schema from "@deepseek-ai/schemastery"; import Schema from "@deepseek-ai/schemastery";
import { armLazyTools } from "./lazy-tools";
import { ObservationService } from "./observation"; import { ObservationService } from "./observation";
import { registerObservationRoutes } from "./observation-http"; import { registerObservationRoutes } from "./observation-http";
import { KeyedExecutor } from "./queue"; import { KeyedExecutor } from "./queue";
@@ -43,6 +44,12 @@ export const Config = Schema.object({
idleIntervalMs: Schema.number() idleIntervalMs: Schema.number()
.default(8000) .default(8000)
.description("Thumbnail refresh cadence for idle sessions; also the recent-activity window."), .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; export type Config = PluginConfig;
@@ -64,6 +71,7 @@ export function apply(
observationEnabled: config.observationEnabled ?? true, observationEnabled: config.observationEnabled ?? true,
thumbnailIntervalMs: config.thumbnailIntervalMs ?? 1500, thumbnailIntervalMs: config.thumbnailIntervalMs ?? 1500,
idleIntervalMs: config.idleIntervalMs ?? 8000, idleIntervalMs: config.idleIntervalMs ?? 8000,
lazyTools: config.lazyTools ?? true,
}; };
const runner = options.runnerFactory?.(resolved.bskPath) ?? createBskRunner(resolved.bskPath); const runner = options.runnerFactory?.(resolved.bskPath) ?? createBskRunner(resolved.bskPath);
const registry = new SessionRegistry(resolved.maxSessions); 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, // Progressive disclosure of the BSK agent skill (catalog entry resident,
// body on demand) through the official skill seam; silent no-op when the // 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 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 // Route registration rides ctx.inject: the webServer service may be provided
// AFTER this plugin loads, and in headless compositions it never appears (the // AFTER this plugin loads, and in headless compositions it never appears (the
// callback simply never runs, leaving the rest of the plugin unaffected). // 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. // are swallowed so one stale handle cannot abort the rest.
ctx.effect(() => { ctx.effect(() => {
return () => { return () => {
removeSuite();
unregisterSkill(); unregisterSkill();
removeRoutes(); removeRoutes();
observation.dispose(); 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; observationEnabled: boolean;
thumbnailIntervalMs: number; thumbnailIntervalMs: number;
idleIntervalMs: 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 { export interface ToolDeps {
@@ -136,9 +143,22 @@ function abortError(): Error {
return error; return error;
} }
/** Register the full browser tool suite. */ /** Register the full browser tool suite; returns the combined unregister disposer. */
export function registerTools(deps: ToolDeps): void { export function registerTools(deps: ToolDeps): () => void {
const { ctx, registry } = deps; // 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( ctx.tools.register(
defineTool({ defineTool({
@@ -934,4 +954,7 @@ export function registerTools(deps: ToolDeps): void {
presentResult: presentTerminalResult, presentResult: presentTerminalResult,
}), }),
); );
return () => {
for (const dispose of disposers.splice(0)) dispose();
};
} }
@@ -71,7 +71,7 @@ describe("dispose cleanup ownership", () => {
disposers.push(fn()); disposers.push(fn());
}, },
}; };
apply(ctx as never, { maxSessions: 5 }, { runnerFactory: () => runner }); apply(ctx as never, { maxSessions: 5, lazyTools: false }, { runnerFactory: () => runner });
const start = tools.get("browser_session_start"); const start = tools.get("browser_session_start");
const snapshot = tools.get("browser_snapshot"); const snapshot = tools.get("browser_snapshot");
@@ -0,0 +1,229 @@
// Lazy tool-schema injection: suite hidden before a successful skill
// invocation, revealed by the model trigger / user gesture / session history,
// idempotent on repeats, and torn down cleanly. Plus the apply-level
// lazyTools two-state wiring.
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
import { describe, expect, it, vi } from "vitest";
import { apply } from "../src/index";
import { armLazyTools, hasSuccessfulSkillInvocation } from "../src/lazy-tools";
import type { BskRunOptions, BskRunResult } from "../src/runner";
function fakeEventCtx(sessions?: { list(): { events: unknown[] }[] }) {
const listeners = new Map<string, (...args: never[]) => void>();
const ctx = {
on: (event: string, listener: (...args: never[]) => void) => {
listeners.set(event, listener);
return () => listeners.delete(event);
},
get: (key: string) => (key === "sessions" ? sessions : undefined),
};
return { ctx: ctx as never, listeners };
}
function callListeners(
listeners: Map<string, (...args: never[]) => void>,
event: string,
...args: unknown[]
): void {
(listeners.get(event) as ((...a: unknown[]) => void) | undefined)?.(...args);
}
describe("armLazyTools", () => {
it("keeps the suite hidden until a successful skill invocation reveals it", () => {
const { ctx, listeners } = fakeEventCtx();
const registerSuite = vi.fn(() => () => {});
armLazyTools(ctx, registerSuite);
expect(registerSuite).not.toHaveBeenCalled();
// wrong tool
callListeners(listeners, "tools/result", { name: "bash", arguments: {} }, { isError: false });
// wrong skill name
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: { name: "other-skill" } },
{ isError: false },
);
// failed invocation
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: { name: "browser-skill" } },
{ isError: true },
);
expect(registerSuite).not.toHaveBeenCalled();
// the real trigger (arguments may arrive as a raw JSON string)
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: '{"name":"browser-skill"}' },
{ isError: false },
);
expect(registerSuite).toHaveBeenCalledTimes(1);
});
it("is idempotent across repeated invocations", () => {
const { ctx, listeners } = fakeEventCtx();
const registerSuite = vi.fn(() => () => {});
armLazyTools(ctx, registerSuite);
for (let i = 0; i < 3; i++) {
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: { name: "browser-skill" } },
{ isError: false },
);
}
expect(registerSuite).toHaveBeenCalledTimes(1);
});
it("reveals on a /browser-skill user gesture (skill-invocation message)", () => {
const { ctx, listeners } = fakeEventCtx();
const registerSuite = vi.fn(() => () => {});
armLazyTools(ctx, registerSuite);
callListeners(
listeners,
"session/event",
{ events: [] },
{
type: "user/message",
data: { source: { kind: "skill-invocation", name: "browser-skill" } },
},
);
expect(registerSuite).toHaveBeenCalledTimes(1);
});
it("restores from session history: hit at boot, on session entry, and miss", () => {
const hitEvents = [
{
type: "tool/call",
data: { callId: "c1", name: "skill", arguments: '{"name":"browser-skill"}' },
},
{ type: "tool/result", data: { message: { callId: "c1", isError: false } } },
];
// boot-time live sessions are scanned
const boot = fakeEventCtx({ list: () => [{ events: hitEvents }] });
const bootRegister = vi.fn(() => () => {});
armLazyTools(boot.ctx, bootRegister);
expect(bootRegister).toHaveBeenCalledTimes(1);
// a session entered later with the same proof
const { ctx, listeners } = fakeEventCtx({ list: () => [] });
const registerSuite = vi.fn(() => () => {});
armLazyTools(ctx, registerSuite);
callListeners(listeners, "session/created", { events: hitEvents });
expect(registerSuite).toHaveBeenCalledTimes(1);
// miss: failed result proves nothing
const miss = fakeEventCtx({
list: () => [
{
events: [
{
type: "tool/call",
data: { callId: "c1", name: "skill", arguments: '{"name":"browser-skill"}' },
},
{ type: "tool/result", data: { message: { callId: "c1", isError: true } } },
],
},
],
});
const missRegister = vi.fn(() => () => {});
armLazyTools(miss.ctx, missRegister);
expect(missRegister).not.toHaveBeenCalled();
});
it("disposes listeners and the revealed suite", () => {
const { ctx, listeners } = fakeEventCtx();
const suiteDispose = vi.fn();
const disarm = armLazyTools(ctx, () => suiteDispose);
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: { name: "browser-skill" } },
{ isError: false },
);
disarm();
expect(suiteDispose).toHaveBeenCalledTimes(1);
expect(listeners.size).toBe(0);
});
});
describe("hasSuccessfulSkillInvocation", () => {
it("pairs call and result by callId and honors gestures", () => {
expect(hasSuccessfulSkillInvocation([])).toBe(false);
expect(
hasSuccessfulSkillInvocation([
{
type: "tool/call",
data: { callId: "x", name: "skill", arguments: { name: "browser-skill" } },
},
{ type: "tool/result", data: { message: { callId: "x", isError: false } } },
]),
).toBe(true);
// result for an unrelated call id does not count
expect(
hasSuccessfulSkillInvocation([
{
type: "tool/call",
data: { callId: "x", name: "skill", arguments: { name: "browser-skill" } },
},
{ type: "tool/result", data: { message: { callId: "y", isError: false } } },
]),
).toBe(false);
expect(
hasSuccessfulSkillInvocation([
{
type: "user/message",
data: { source: { kind: "skill-invocation", name: "browser-skill" } },
},
]),
).toBe(true);
});
});
describe("lazyTools wiring in apply()", () => {
function applyHarness(config: Record<string, unknown>) {
const tools = new Map<string, ToolDefinition>();
const listeners = new Map<string, (...args: never[]) => void>();
const ctx = {
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
get: () => undefined,
inject: () => {},
effect: () => {},
on: (event: string, listener: (...args: never[]) => void) => {
listeners.set(event, listener);
return () => listeners.delete(event);
},
};
const runner = {
async run(): Promise<BskRunResult> {
return { code: 0, stdout: "{}", stderr: "", timedOut: false, aborted: false };
},
killAll() {},
killFor: () => 0,
};
apply(ctx as never, config, { runnerFactory: () => runner as never });
return { tools, listeners };
}
it("lazyTools: false registers the suite at apply time", () => {
const { tools } = applyHarness({ lazyTools: false });
expect(tools.has("browser_session_start")).toBe(true);
expect(tools.has("browser_emulate")).toBe(true);
});
it("lazyTools: true hides the suite until the skill fires", () => {
const { tools, listeners } = applyHarness({ lazyTools: true });
expect(tools.has("browser_session_start")).toBe(false);
callListeners(
listeners,
"tools/result",
{ name: "skill", arguments: { name: "browser-skill" } },
{ isError: false },
);
expect(tools.has("browser_session_start")).toBe(true);
});
});
@@ -16,6 +16,7 @@ const CONFIG: PluginConfig = {
observationEnabled: false, observationEnabled: false,
thumbnailIntervalMs: 1500, thumbnailIntervalMs: 1500,
idleIntervalMs: 8000, idleIntervalMs: 8000,
lazyTools: false,
}; };
/** An observation service with the feature off: instrumentation becomes a no-op. */ /** An observation service with the feature off: instrumentation becomes a no-op. */