diff --git a/packages/dsh-plugin-browserskill/skill/prelude.md b/packages/dsh-plugin-browserskill/skill/prelude.md index fb5a6b9..ac1e736 100644 --- a/packages/dsh-plugin-browserskill/skill/prelude.md +++ b/packages/dsh-plugin-browserskill/skill/prelude.md @@ -5,6 +5,11 @@ through this plugin's `browser_*` tools. Each tool spawns `bsk --json` — same engine the canonical CLI skill documents — so the workflow, judgment rules, and 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 The tools below are thin, structured wrappers over the bsk CLI verbs of the same diff --git a/packages/dsh-plugin-browserskill/src/index.ts b/packages/dsh-plugin-browserskill/src/index.ts index 7a580f5..ace19a9 100644 --- a/packages/dsh-plugin-browserskill/src/index.ts +++ b/packages/dsh-plugin-browserskill/src/index.ts @@ -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(); diff --git a/packages/dsh-plugin-browserskill/src/lazy-tools.ts b/packages/dsh-plugin-browserskill/src/lazy-tools.ts new file mode 100644 index 0000000..fa2347c --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/lazy-tools.ts @@ -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(); + 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?.(); + }; +} diff --git a/packages/dsh-plugin-browserskill/src/tools.ts b/packages/dsh-plugin-browserskill/src/tools.ts index 77dfa63..a29169c 100644 --- a/packages/dsh-plugin-browserskill/src/tools.ts +++ b/packages/dsh-plugin-browserskill/src/tools.ts @@ -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[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(); + }; } diff --git a/packages/dsh-plugin-browserskill/tests/dispose.test.ts b/packages/dsh-plugin-browserskill/tests/dispose.test.ts index 6e820de..1bfc9a6 100644 --- a/packages/dsh-plugin-browserskill/tests/dispose.test.ts +++ b/packages/dsh-plugin-browserskill/tests/dispose.test.ts @@ -71,7 +71,7 @@ describe("dispose cleanup ownership", () => { 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 snapshot = tools.get("browser_snapshot"); diff --git a/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts new file mode 100644 index 0000000..324a5f9 --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/lazy-tools.test.ts @@ -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 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 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) { + const tools = new Map(); + const listeners = new Map 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 { + 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); + }); +}); diff --git a/packages/dsh-plugin-browserskill/tests/tools.test.ts b/packages/dsh-plugin-browserskill/tests/tools.test.ts index e62cde6..5762748 100644 --- a/packages/dsh-plugin-browserskill/tests/tools.test.ts +++ b/packages/dsh-plugin-browserskill/tests/tools.test.ts @@ -16,6 +16,7 @@ const CONFIG: PluginConfig = { observationEnabled: false, thumbnailIntervalMs: 1500, idleIntervalMs: 8000, + lazyTools: false, }; /** An observation service with the feature off: instrumentation becomes a no-op. */