mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(dsh-plugin-browserskill): publish the BSK skill through ctx.skills
The plugin exposed only tools; the BrowserSkill agent skill never reached the harness's <available_skills> catalog. Register it as an embedded runtime skill (progressive disclosure: catalog entry resident, body on skill-tool invocation) via the official ctx.skills seam — silent no-op when the composition lacks it. Content is build-time assembled so there is one source of truth: skill/prelude.md (committed, dsh-specific: tool<->CLI map, owned-session semantics, plugin-only overrides) + the canonical repo-root skill/SKILL.md verbatim (the same file crates/bsk-cli/build.rs mirrors; frontmatter stripped, name/description supplied by the registration). Generated into src/skill-content.generated.ts (gitignored) — registration and every pre-step snapshot are pure in-memory reads: no disk, no process, no daemon. Scripts: build:skill chained into build/test/typecheck (same pattern as build:css); biome excludes the generated artifacts. Tests: +3 (catalog content, in-memory weight, silent degradation). 116/116 green.
This commit is contained in:
@@ -40,6 +40,10 @@
|
||||
"!**/node_modules/**",
|
||||
"!**/dist/**",
|
||||
"!packages/dsh-plugin-browserskill/lib/**",
|
||||
"!packages/dsh-plugin-browserskill/src/skill-content.generated.ts",
|
||||
"!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css",
|
||||
"!packages/dsh-plugin-browserskill/.bsk-ui.raw.css",
|
||||
"!packages/dsh-plugin-browserskill/.bsk-ui.input.css",
|
||||
"!**/target/**",
|
||||
"!**/.wxt/**",
|
||||
"!**/.output/**",
|
||||
|
||||
@@ -2,3 +2,4 @@ lib/
|
||||
src/client/bsk-ui.nomodule.css
|
||||
.bsk-ui.input.css
|
||||
.bsk-ui.raw.css
|
||||
src/skill-content.generated.ts
|
||||
|
||||
@@ -23,6 +23,19 @@ extension keep owning the actual browser control — this package is a thin, wel
|
||||
| `browser_screenshot` | `bsk screenshot` | PNG capture of the tab or a ref-cropped element; inlines the image when the deployment supports image input, otherwise returns a file path. |
|
||||
| `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. |
|
||||
|
||||
## Agent skill (progressive disclosure)
|
||||
|
||||
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
||||
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
||||
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
||||
tool. The body is assembled at build time from two parts, so there is exactly one source of
|
||||
truth: a dsh-specific prelude (`skill/prelude.md` — tool↔CLI map, owned-session semantics,
|
||||
plugin-only overrides) followed verbatim by the canonical CLI skill (`skill/SKILL.md` at the
|
||||
repo root — the same file `crates/bsk-cli/build.rs` mirrors for the CLI package; workflows,
|
||||
stop-when-done rules, refs usage, sandbox rules). Registration and every pre-step catalog
|
||||
snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
||||
seam degrade silently.
|
||||
|
||||
## Multi-session model
|
||||
|
||||
One agent conversation can drive several browser sessions at once:
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build-client-css.mjs && tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "pnpm run build:css && vitest run",
|
||||
"build:css": "node scripts/build-client-css.mjs"
|
||||
"build": "node scripts/build-client-css.mjs && node scripts/build-skill-content.mjs && tsdown",
|
||||
"typecheck": "pnpm run build:skill && tsc --noEmit",
|
||||
"test": "pnpm run build:css && pnpm run build:skill && vitest run",
|
||||
"build:css": "node scripts/build-client-css.mjs",
|
||||
"build:skill": "node scripts/build-skill-content.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Assemble the plugin's skill content: the dsh-specific prelude (committed in
|
||||
// this package at skill/prelude.md) followed VERBATIM by the canonical CLI
|
||||
// skill body (repo-root skill/SKILL.md — the same source crates/bsk-cli's
|
||||
// build.rs mirrors, so the two can never drift apart). The original's YAML
|
||||
// frontmatter is stripped: name/description are supplied by the registration.
|
||||
// Output: src/skill-content.generated.ts (gitignored, built before tsdown and
|
||||
// before vitest) so the markdown ships inlined in the bundle and registration
|
||||
// is a pure in-memory read — never a per-step disk access.
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = join(here, "..");
|
||||
const prelude = readFileSync(join(pkg, "skill", "prelude.md"), "utf8");
|
||||
const upstream = readFileSync(join(pkg, "..", "..", "skill", "SKILL.md"), "utf8");
|
||||
|
||||
function stripFrontmatter(markdown) {
|
||||
if (!markdown.startsWith("---\n")) return markdown;
|
||||
const end = markdown.indexOf("\n---\n", 4);
|
||||
return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, "");
|
||||
}
|
||||
|
||||
const NAME = "browser-skill";
|
||||
const DESCRIPTION =
|
||||
"Browser automation against the user's logged-in Chromium through this plugin's browser_* " +
|
||||
"tools (bsk engine): visit and read pages, fill forms, click through flows, scrape data, " +
|
||||
"smoke-test UI changes. Use when a task needs a real browser; requires the bsk CLI and the " +
|
||||
"browser-skill extension connected.";
|
||||
|
||||
const content = `${prelude.trim()}\n\n${stripFrontmatter(upstream).trim()}\n`;
|
||||
|
||||
const banner =
|
||||
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
|
||||
"// Sources: skill/prelude.md (this package) + ../../../skill/SKILL.md (repo root).\n";
|
||||
|
||||
writeFileSync(
|
||||
join(pkg, "src", "skill-content.generated.ts"),
|
||||
`${banner}export const BSK_SKILL_NAME = ${JSON.stringify(NAME)};\n` +
|
||||
`export const BSK_SKILL_DESCRIPTION = ${JSON.stringify(DESCRIPTION)};\n` +
|
||||
`export const BSK_SKILL_MARKDOWN = ${JSON.stringify(content)};\n`,
|
||||
);
|
||||
console.log(`skill-content.generated.ts written (${content.length} chars)`);
|
||||
@@ -0,0 +1,50 @@
|
||||
# browser-skill (dsh edition)
|
||||
|
||||
Drive the user's **real Chromium browser** (logins, cookies, Agent Window isolation)
|
||||
through this plugin's `browser_*` tools. Each tool spawns `bsk <cmd> --json` — the
|
||||
same engine the canonical CLI skill documents — so the workflow, judgment rules, and
|
||||
safety constraints are identical.
|
||||
|
||||
## Tool ↔ CLI map
|
||||
|
||||
The tools below are thin, structured wrappers over the bsk CLI verbs of the same
|
||||
name; `session` parameters take the 4-letter session id (no `--session` flag
|
||||
spelling here):
|
||||
|
||||
| dsh tool | bsk equivalent |
|
||||
|---|---|
|
||||
| `browser_session_start` / `browser_session_stop` / `browser_session_list` | `bsk session start/stop/list` |
|
||||
| `browser_navigate` | `bsk navigate` |
|
||||
| `browser_snapshot` / `browser_observe` | `bsk snapshot` / `bsk observe` |
|
||||
| `browser_click` / `browser_fill` / `browser_press` | `bsk click` / `bsk fill` / `bsk press` |
|
||||
| `browser_screenshot` | `bsk screenshot` |
|
||||
| `browser_emulate` | `bsk emulate` |
|
||||
|
||||
(`bsk console` / `bsk network` have no tool counterpart in this plugin yet.)
|
||||
|
||||
Plugin-specific semantics that override or narrow the CLI rules below:
|
||||
|
||||
- **Owned sessions only**: this plugin sees and acts on just the sessions IT
|
||||
started via `browser_session_start`. `browser_session_list` shows only those
|
||||
(with the current marker); passing a session id created by another program
|
||||
(or person) sharing the bsk daemon is refused outright — never retry those
|
||||
with the same id.
|
||||
- **Current session**: omitted `session` parameters resolve to the most
|
||||
recently started or used OWNED session. With several sessions in flight,
|
||||
pass `session` explicitly to avoid surprises.
|
||||
- **Screenshots**: when the model route accepts images, `browser_screenshot`
|
||||
returns the PNG inline as an image attachment; otherwise it returns the
|
||||
saved file path.
|
||||
- **`browser_emulate mobile`**: requires `width`+`height` — the bsk daemon
|
||||
refuses `--mobile` without viewport dimensions.
|
||||
- **Live observation**: in the dsh Web UI, every owned session is watched by
|
||||
the floating overlay (thumbnails, action, interrupt) — observational side
|
||||
traffic is free and never steals your command turn; just keep acting.
|
||||
|
||||
The canonical CLI skill body follows verbatim (source of truth: `skill/SKILL.md`
|
||||
in the BrowserSkill repo, mirrored by build into `crates/bsk-cli/skill/SKILL.md`).
|
||||
Read `bsk <verb> --session <id>` as "the `browser_<verb>` tool with
|
||||
`session: <id>`"; everything else — when to use, the stop-when-done rules, the
|
||||
interaction loop, refs, sandbox rules — applies unchanged.
|
||||
|
||||
---
|
||||
@@ -17,6 +17,7 @@ import { registerObservationRoutes } from "./observation-http";
|
||||
import { KeyedExecutor } from "./queue";
|
||||
import { type BskRunner, createBskRunner } from "./runner";
|
||||
import { SessionRegistry } from "./sessions";
|
||||
import { registerBskSkill } from "./skill";
|
||||
import { type PluginConfig, registerTools } from "./tools";
|
||||
|
||||
export const name = "dsh-plugin-browserskill";
|
||||
@@ -80,6 +81,10 @@ 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.
|
||||
const unregisterSkill = registerBskSkill(ctx);
|
||||
// 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).
|
||||
@@ -110,6 +115,7 @@ export function apply(
|
||||
// are swallowed so one stale handle cannot abort the rest.
|
||||
ctx.effect(() => {
|
||||
return () => {
|
||||
unregisterSkill();
|
||||
removeRoutes();
|
||||
observation.dispose();
|
||||
runner.killAll();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Progressive disclosure of the BrowserSkill agent skill through the harness's
|
||||
* official skill seam (`ctx.skills`). The catalog entry (name + description)
|
||||
* is resident; the body is loaded only when the model invokes the `skill`
|
||||
* tool. The markdown is build-time assembled into a static module (dsh
|
||||
* prelude + the canonical CLI skill body verbatim), so registration and every
|
||||
* pre-step catalog snapshot are pure in-memory reads — no disk, no process,
|
||||
* no daemon.
|
||||
*/
|
||||
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import {
|
||||
BSK_SKILL_DESCRIPTION,
|
||||
BSK_SKILL_MARKDOWN,
|
||||
BSK_SKILL_NAME,
|
||||
} from "./skill-content.generated";
|
||||
|
||||
/** Structural view of the skill registry seam (absent in bare compositions). */
|
||||
interface SkillsLike {
|
||||
register(skill: {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
}): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the BSK skill as an embedded runtime skill. Returns the unregistration
|
||||
* disposer. A composition without the `skills` service (or with dsh-tool-skill
|
||||
* retired) leaves the rest of the plugin unaffected — the no-op is silent.
|
||||
*/
|
||||
export function registerBskSkill(ctx: Context): () => void {
|
||||
const skills = ctx.get("skills") as SkillsLike | undefined | null;
|
||||
if (skills == null || typeof skills.register !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
return skills.register({
|
||||
name: BSK_SKILL_NAME,
|
||||
description: BSK_SKILL_DESCRIPTION,
|
||||
content: BSK_SKILL_MARKDOWN,
|
||||
// Prompt-visible origin bucket: packaged with a plugin, not user/project files.
|
||||
source: "bundled",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// BSK skill injection: registration wiring, catalog content, progressive-load
|
||||
// weight, and silent degradation without the skill seam.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerBskSkill } from "../src/skill";
|
||||
|
||||
function fakeCtx(skills?: unknown) {
|
||||
return { get: (key: string) => (key === "skills" ? skills : undefined) } as never;
|
||||
}
|
||||
|
||||
describe("registerBskSkill", () => {
|
||||
it("registers the catalog entry with the assembled body", () => {
|
||||
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
|
||||
const skills = {
|
||||
register(skill: Record<string, unknown>) {
|
||||
const disposer = () => {
|
||||
skill.__disposed = true;
|
||||
};
|
||||
registrations.push({ skill, disposer });
|
||||
return disposer;
|
||||
},
|
||||
};
|
||||
const unregister = registerBskSkill(fakeCtx(skills));
|
||||
expect(registrations).toHaveLength(1);
|
||||
const skill = registrations[0].skill;
|
||||
expect(skill.name).toBe("browser-skill");
|
||||
expect(typeof skill.description).toBe("string");
|
||||
expect(String(skill.description)).toContain("browser_*");
|
||||
// body = dsh prelude + canonical CLI skill body
|
||||
const content = String(skill.content);
|
||||
expect(content).toContain("browser_observe");
|
||||
expect(content).toContain("Owned sessions only");
|
||||
expect(content).toContain("Mandatory workflow");
|
||||
expect(content).toContain("Refs invalidate after navigation");
|
||||
expect(content.length).toBeGreaterThan(10_000);
|
||||
expect(skill.source).toBe("bundled");
|
||||
// the original YAML frontmatter must not leak into the body
|
||||
expect(content.includes("name: browser-skill\ndescription:")).toBe(false);
|
||||
// disposer passthrough
|
||||
unregister();
|
||||
expect(skill.__disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps one in-memory copy: repeated reads share the same content", () => {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
const skills = {
|
||||
register(skill: Record<string, unknown>) {
|
||||
captured = skill;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
registerBskSkill(fakeCtx(skills));
|
||||
// Pre-step catalog snapshots re-read the registration: the body must be
|
||||
// the identical in-memory string every time (no reload, no copy).
|
||||
expect(captured?.content).toBe(captured?.content);
|
||||
expect(typeof captured?.content).toBe("string");
|
||||
});
|
||||
|
||||
it("degrades silently when the skills seam is absent or foreign", () => {
|
||||
expect(() => registerBskSkill(fakeCtx())).not.toThrow();
|
||||
expect(() => registerBskSkill(fakeCtx(null))).not.toThrow();
|
||||
expect(() => registerBskSkill(fakeCtx({}))).not.toThrow();
|
||||
const disposer = registerBskSkill(fakeCtx());
|
||||
expect(typeof disposer).toBe("function");
|
||||
expect(() => disposer()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user