Merge pull request #131 from Tencent/feat/dsh-browser-tools-parity

feat(dsh-plugin): add Browser tool parity and dedicated Harness skill
This commit is contained in:
Zhang GH
2026-08-27 16:29:30 +08:00
committed by GitHub
20 changed files with 2410 additions and 138 deletions
+31 -10
View File
@@ -20,22 +20,43 @@ extension keep owning the actual browser control — this package is a thin, wel
| `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. | | `browser_snapshot` | `bsk snapshot` | Indented aria-tree snapshot with `@eN` refs for interaction tools. |
| `browser_observe` | `bsk observe` | Semantic VOM observation (read-only) with `@eN` refs. | | `browser_observe` | `bsk observe` | Semantic VOM observation (read-only) with `@eN` refs. |
| `browser_click` | `bsk click` | Click a snapshot ref or CSS selector (button / click-count options). | | `browser_click` | `bsk click` | Click a snapshot ref or CSS selector (button / click-count options). |
| `browser_hover` | `bsk hover` | Hover a ref or selector and allow hover-triggered UI to settle. |
| `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). | | `browser_fill` | `bsk fill` | Fill an input / textarea / contenteditable (clears first by default). |
| `browser_select` | `bsk select` | Select one or more option values on a `<select>`. |
| `browser_press` | `bsk press` | Dispatch a key or combo, optionally focusing a target first. | | `browser_press` | `bsk press` | Dispatch a key or combo, optionally focusing a target first. |
| `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_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. | | `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. |
| `browser_tab_list` | `bsk tab list` | List user and/or Agent Window tabs visible to an owned session. |
| `browser_tab_create` | `bsk tab create` | Create a foreground or background tab in the Agent Window. |
| `browser_tab_select` | `bsk tab select` | Focus an Agent Window tab. |
| `browser_tab_close` | `bsk tab close` | Close an Agent Window tab. |
| `browser_tab_borrow` | `bsk tab borrow` | Move a user tab into the Agent Window for controlled interaction. |
| `browser_tab_return` | `bsk tab return` | Return a borrowed tab to its original user window. |
| `browser_navigate_back` / `browser_navigate_forward` | `bsk navigate-back` / `bsk navigate-forward` | Traverse tab history and wait for a lifecycle phase. |
| `browser_reload` | `bsk reload` | Reload the active tab, optionally bypassing cache. |
| `browser_wait_for_navigation` | `bsk wait-for-navigation` | Wait explicitly for a page lifecycle event. |
| `browser_request_help` | `bsk request-help` | Pause for login, captcha, OTP, confirmation, or another human browser step. |
| `browser_get_html` | `bsk get-html` | Read raw document or ref-scoped HTML after semantic observation is insufficient. |
| `browser_console` | `bsk console` | Read buffered console/log/exception entries without evaluating JavaScript. |
| `browser_network` | `bsk network` | Read buffered network response/failure metadata. |
| `browser_window_resize` | `bsk window resize` | Resize the owned session's Agent Window. |
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
DeepSeek Harness lifecycle before it can be added safely.
## Agent skill (progressive disclosure) ## Agent skill (progressive disclosure)
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's 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 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` 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 tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
truth: a dsh-specific prelude (`skill/prelude.md` — tool↔CLI map, owned-session semantics, `browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
plugin-only overrides) followed verbatim by the canonical CLI skill (`skill/SKILL.md` at the concatenated: its command examples belong to a different execution interface and would bypass
repo root — the same file `crates/bsk-cli/build.rs` mirrors for the CLI package; workflows, the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
stop-when-done rules, refs usage, sandbox rules). Registration and every pre-step catalog The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill missing supported browser tools before embedding the Markdown. Registration and every pre-step
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
seam degrade silently. seam degrade silently.
## Multi-session model ## Multi-session model
@@ -88,7 +109,7 @@ All fields are optional and validated through the plugin's Schemastery `Config`:
# lazyTools: true # reveal browser_* tools only after the skill is invoked # lazyTools: true # reveal browser_* tools only after the skill is invoked
``` ```
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the eleven - **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the 28
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the `browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
`browser-skill` skill is actually invoked — the skill catalog entry is the only `browser-skill` skill is actually invoked — the skill catalog entry is the only
advertisement. One successful invocation (model tool call, or a `/browser-skill` user advertisement. One successful invocation (model tool call, or a `/browser-skill` user
@@ -152,7 +173,7 @@ shell's theme cannot bleed back in.
card). Screenshots additionally attach the image itself when the host mounts an attachment store card). Screenshots additionally attach the image itself when the host mounts an attachment store
and the active model route declares image input; otherwise the PNG path is returned. and the active model route declares image input; otherwise the PNG path is returned.
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships - **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
`lib/client.js`, which registers a keyed `tool.call.toolview` view for `browser_screenshot`. The `lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_screenshot`. The
custom view keeps the terminal block (command + output) and, when the settled result carries an custom view keeps the terminal block (command + output) and, when the settled result carries an
image block, resolves the durable attachment through the client session's authorized image block, resolves the durable attachment through the client session's authorized
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms. `readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
@@ -183,8 +204,8 @@ as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id stays
Trigger it by pushing a tag that matches `package.json`'s `version`: Trigger it by pushing a tag that matches `package.json`'s `version`:
```sh ```sh
git tag dsh-plugin-v0.1.0 git tag dsh-plugin-v0.1.2
git push origin dsh-plugin-v0.1.0 git push origin dsh-plugin-v0.1.2
``` ```
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
@@ -1,7 +1,7 @@
{ {
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin", "name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill (bsk) browser automation to the model", "description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
"version": "0.1.1", "version": "0.1.2",
"license": "MIT", "license": "MIT",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
@@ -1,20 +1,15 @@
// Assemble the plugin's skill content: a dsh-specific routing prelude, the // Embed the DeepSeek Harness-specific browser skill at build time. The model-facing
// canonical CLI skill body verbatim (repo-root skill/SKILL.md — the same source // skill deliberately documents only injected browser_* tools; the repository's CLI
// crates/bsk-cli's build.rs mirrors), then a dsh routing reminder postlude. The // skill is a separate interface and must never be concatenated here.
// original's YAML frontmatter is stripped: name/description are supplied by the
// registration.
// Output: src/skill-content.generated.ts (gitignored, built before tsdown and // Output: src/skill-content.generated.ts (gitignored, built before tsdown and
// before vitest) so the markdown ships inlined in the bundle and registration // before vitest), so registration is a pure in-memory read.
// is a pure in-memory read — never a per-step disk access.
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const pkg = join(here, ".."); const pkg = join(here, "..");
const prelude = readFileSync(join(pkg, "skill", "prelude.md"), "utf8"); const source = readFileSync(join(pkg, "skill", "SKILL.md"), "utf8");
const upstream = readFileSync(join(pkg, "..", "..", "skill", "SKILL.md"), "utf8");
const postlude = readFileSync(join(pkg, "skill", "postlude.md"), "utf8");
function stripFrontmatter(markdown) { function stripFrontmatter(markdown) {
if (!markdown.startsWith("---\n")) return markdown; if (!markdown.startsWith("---\n")) return markdown;
@@ -22,18 +17,69 @@ function stripFrontmatter(markdown) {
return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, ""); return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, "");
} }
const SUPPORTED_BROWSER_TOOLS = [
"browser_session_start",
"browser_session_stop",
"browser_session_list",
"browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_reload",
"browser_wait_for_navigation",
"browser_snapshot",
"browser_observe",
"browser_get_html",
"browser_screenshot",
"browser_console",
"browser_network",
"browser_click",
"browser_hover",
"browser_fill",
"browser_select",
"browser_press",
"browser_tab_list",
"browser_tab_create",
"browser_tab_select",
"browser_tab_close",
"browser_tab_borrow",
"browser_tab_return",
"browser_request_help",
"browser_emulate",
"browser_window_resize",
];
function validateModelFacingSkill(markdown) {
if (/\bbsk\b/i.test(markdown)) {
throw new Error("DSH skill must not expose the internal CLI name");
}
if (/```(?:bash|sh|shell)\b/i.test(markdown)) {
throw new Error("DSH skill must not contain command-line code blocks");
}
const mentioned = new Set(markdown.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? []);
const supported = new Set(SUPPORTED_BROWSER_TOOLS);
const unknown = [...mentioned].filter((name) => !supported.has(name));
const missing = SUPPORTED_BROWSER_TOOLS.filter((name) => !mentioned.has(name));
if (unknown.length > 0) {
throw new Error(`DSH skill mentions unsupported browser tools: ${unknown.join(", ")}`);
}
if (missing.length > 0) {
throw new Error(`DSH skill omits supported browser tools: ${missing.join(", ")}`);
}
}
const NAME = "browser-skill"; const NAME = "browser-skill";
const DESCRIPTION = const DESCRIPTION =
"Browser automation against the user's logged-in Chromium through this plugin's injected browser_* tools; never invoke bsk through bash or shell. " + "Browser automation against the user's logged-in Chromium through this plugin's injected " +
"tools (bsk engine): visit and read pages, fill forms, click through flows, scrape data, " + "browser_* tools. Use to visit and read pages, fill forms, click through flows, inspect tabs, " +
"smoke-test UI changes. Use when a task needs a real browser; requires the bsk CLI and the " + "debug page activity, or smoke-test UI changes in a managed Agent Window.";
"browser-skill extension connected.";
const content = `${prelude.trim()}\n\n${stripFrontmatter(upstream).trim()}\n\n${postlude.trim()}\n`; const content = `${stripFrontmatter(source).trim()}\n`;
validateModelFacingSkill(content);
const banner = const banner =
"// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" + "// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" +
"// Sources: skill/prelude.md + ../../../skill/SKILL.md + skill/postlude.md.\n"; "// Source: skill/SKILL.md (DeepSeek Harness browser_* tool instructions).\n";
writeFileSync( writeFileSync(
join(pkg, "src", "skill-content.generated.ts"), join(pkg, "src", "skill-content.generated.ts"),
@@ -0,0 +1,271 @@
---
name: browser-skill
description: |
Use the injected browser_* tools to automate the user's logged-in Chromium
browser in a managed Agent Window.
---
# browser-skill for DeepSeek Harness
Drive the user's real Chromium browser through the structured `browser_*` tools provided by this
plugin. The browser keeps the user's existing logins and cookies while isolating automation in an
Agent Window. Use the tool schemas exactly as exposed by DeepSeek Harness; arguments are structured
fields, not command-line flags.
## Tool availability
Loading this skill reveals the browser tools for the rest of the conversation. If the tools were
hidden before this skill was loaded, start browser work only after the skill invocation succeeds.
All browser operations must use the injected tools directly. Do not translate the examples in this
document into terminal commands or invoke another process to control the browser. Direct tool calls
preserve session ownership, the live observation window, tool cards, cancellation, and cleanup.
## When to use
- Visit and read pages that require the user's existing browser session.
- Fill forms, select options, click through bounded flows, or smoke-test a web UI.
- Inspect tabs, page structure, screenshots, console messages, or network metadata.
- Work with a user tab after explicitly borrowing it into the Agent Window.
Do not use browser automation for tasks that need only local files, a purpose-built API, or an
explanation. Never extract credentials, cookies, tokens, or other secrets. Do not keep control of a
personal tab longer than the immediate task requires.
## Mandatory workflow
Every browser task follows this lifecycle:
1. Call `browser_session_start` and retain its returned `sessionId`.
2. Use that session for every browser operation. Pass `session` explicitly when more than one
session exists.
3. Call `browser_session_stop` when the bounded goal is reached or an error ends the task. Treat
cleanup like a `finally` path unless the user explicitly asks to keep the session open.
Conceptual tool-call sequence:
```text
browser_session_start({ url: "https://example.com" })
browser_observe({ session: sessionId })
browser_click({ session: sessionId, target: "@e4" })
browser_observe({ session: sessionId })
browser_session_stop({ session: sessionId })
```
The plugin owns only sessions created by `browser_session_start`. `browser_session_list` reports
only those owned sessions. Never guess or reuse a session id from another program. When `session`
is omitted, most tools use the most recently started or used owned session; explicit ids are safer
for multi-session work.
## Stop when the goal is met
Browser work is a bounded task, not open-ended browsing.
1. Define one observable success condition from the user's request.
2. Take the shortest path: observe, act, then perform at most one relevant check.
3. Stop immediately when success is visible. Do not refresh, navigate, click, or perform extra
verification after success.
4. If a human-only step appears, use `browser_request_help`. If an action fails twice without
progress, pause instead of brute-forcing.
5. When uncertain, make at most one fresh observation. If the state is still ambiguous, ask the
user rather than continuing to click.
## Core interaction loop
Use `browser_navigate` to open a destination in the active Agent Window tab, then inspect the page
before interacting:
1. `browser_observe` is the primary semantic view. It returns roles, states, text, and `@eN` refs,
including hints about conditional hover or focus surfaces.
2. `browser_snapshot` is the static accessibility-tree fallback when the semantic view is
insufficient or a stricter tree is useful.
3. Prefer a fresh `@eN` ref over a raw CSS selector for `browser_click`, `browser_hover`,
`browser_fill`, `browser_select`, and targeted `browser_press` calls.
4. Observe again after navigation or a meaningful DOM change before using another ref.
Refs invalidate after navigation and may also become stale after large DOM changes. Never carry a
ref to a new page. If an observation marks a control as hover-triggered, call `browser_hover` on the
trigger, observe again, and then act on the newly visible ref.
Use interaction tools according to their schemas:
- `browser_click`: click a ref or selector; optionally choose mouse button and click count.
- `browser_hover`: move to a ref or selector and allow hover UI to settle; then observe again.
- `browser_fill`: clear and fill a text-capable element; set `noClear` only when appending is
intentional.
- `browser_select`: pass `values` as an array, including for a single option.
- `browser_press`: send a key or combo, optionally focusing `target` first.
Actions may trigger a navigation after the action itself returns. Use
`browser_wait_for_navigation` only when that lifecycle wait is actually needed, then observe the
new page.
## Observation and page reading priority
Escalate page reading only as needed:
1. `browser_observe` for normal semantic understanding and interaction refs.
2. `browser_snapshot` for a stricter static accessibility tree.
3. `browser_get_html` for hidden DOM, metadata, or exact markup that the semantic views cannot
provide. A `ref` must come from a fresh observation; keep `maxBytes` bounded.
4. `browser_screenshot` for layout, styling, canvas, image content, or another genuinely visual
question. Pass a fresh `ref` to crop to one element, or omit it for the visible tab.
Do not start with raw HTML or a screenshot merely to discover ordinary controls. Screenshots may
return an inline image or a saved path depending on the deployment.
## Tabs and the Agent Window boundary
Write actions normally affect only Agent Window tabs.
- `browser_tab_list` lists `agent`, `user`, or `all` tabs visible to the owned session. Use
`scope: "user"` to locate a user tab before borrowing it.
- `browser_tab_create` creates an Agent Window tab. It is active by default; set `active: false`
for a background tab.
- `browser_tab_select` focuses an Agent Window tab.
- `browser_tab_close` closes an Agent Window tab.
- `browser_tab_borrow` moves a chosen user tab into the Agent Window before any write action.
- `browser_tab_return` returns a borrowed tab to its original window and position. Return it as
soon as the immediate task is finished; stopping the session is only the fallback cleanup.
Use only a `tabId` returned by `browser_tab_list` or `browser_tab_create`. Do not assume a tab id
from page position or title.
## Navigation and lifecycle
- `browser_navigate` opens a URL and can wait for a requested lifecycle phase.
- `browser_navigate_back` and `browser_navigate_forward` move one history entry.
- `browser_reload` reloads the current tab; use `hard: true` only when bypassing cache is necessary.
- `browser_wait_for_navigation` waits for a lifecycle event after an action that did not already
wait. Avoid speculative waits when no navigation is expected.
After navigation, history traversal, or reload, discard old refs and call `browser_observe` or
`browser_snapshot` again.
## Ask the user for browser help
Call `browser_request_help` for login, captcha, OTP, payment confirmation, consent, or another step
that requires the user. Give a clear `prompt`; when the prompt refers to a concrete control, pass
its fresh ref or selector in `targets` so the overlay can highlight it. Use `title` only when it
adds clarity.
Optional `completionCriteria` can finish the pause automatically when the page has an explicit,
stable success signal. It supports `any` or `all` conditions using `urlContains`, `urlMatches`,
`selectorExists`, `selectorMissing`, `textExists`, or `textMissing`, plus `stableForMs`.
```text
browser_request_help({
session: sessionId,
title: "Sign in required",
prompt: "Complete sign-in, then return control after the account page appears.",
targets: ["@e7"],
timeoutMs: 300000,
completionCriteria: {
any: [{ urlContains: "/account" }, { selectorExists: "[data-testid='account-menu']" }],
stableForMs: 1000
}
})
```
Interpret outcomes deliberately:
- `continued` or `completed`: the user returned control or the explicit detector matched. Observe
again before reasoning about the new state or using refs.
- `cancelled`: stop the requested flow.
- `timed_out`: report the timeout; do not repeatedly reopen the prompt.
- `disabled`: unattended mode does not allow help; proceed only if safe or stop gracefully.
- `navigated`: legacy outcome; do not treat navigation alone as proof of success.
## Debugging without page scripting
Use the read-only debugging tools only when relevant to the user's task:
- `browser_console` reads buffered console messages, browser logs, and JavaScript exceptions.
Set `includeStack: true` when stack frames are useful.
- `browser_network` reads buffered response and failure metadata. It does not return request or
response headers or bodies and does not modify traffic.
Both tools accept `tabId`, `since`, `limit`, and `maxTextChars`. Their result returns `nextSince`;
pass that value as the next `since` cursor instead of repeatedly requesting the same entries.
Arbitrary page script evaluation and interaction recording are intentionally unsupported in this
DeepSeek Harness plugin. Do not invent tool names or route around this limitation. If the task
strictly requires either capability, explain that it is not available in this phase.
## Window and device controls
- `browser_window_resize` changes the Agent Window's outer `width` and `height`; each dimension
must be from 100 through 7680 CSS pixels.
- `browser_emulate` applies a built-in device preset or explicit viewport settings to the active
tab. Explicit `width` and `height` must be supplied together. `mobile: true` also requires both
dimensions. Use `off: true` alone to clear emulation. Emulation is per-tab and new tabs do not
inherit it.
## Tool reference
### Sessions
| Tool | Purpose |
| --- | --- |
| `browser_session_start` | Start an owned Agent Window session, optionally with URL, size, browser instance, focus behavior, or device preset. |
| `browser_session_stop` | Stop an owned session and close its Agent Window. |
| `browser_session_list` | List owned sessions and identify the current one. |
### Navigation and reading
| Tool | Purpose |
| --- | --- |
| `browser_navigate` | Navigate the active tab to a URL. |
| `browser_navigate_back` | Go back one history entry. |
| `browser_navigate_forward` | Go forward one history entry. |
| `browser_reload` | Reload the active tab, optionally bypassing cache. |
| `browser_wait_for_navigation` | Wait for an expected page lifecycle event. |
| `browser_observe` | Read the primary semantic page representation with refs. |
| `browser_snapshot` | Read the static accessibility-tree fallback with refs. |
| `browser_get_html` | Read raw document or ref-scoped HTML as a last resort. |
| `browser_screenshot` | Capture the visible tab or a ref-cropped element. |
### Interaction
| Tool | Purpose |
| --- | --- |
| `browser_click` | Click a ref or CSS selector. |
| `browser_hover` | Hover a ref or selector to reveal conditional UI. |
| `browser_fill` | Fill a text-capable element. |
| `browser_select` | Select one or more option values. |
| `browser_press` | Send a keyboard key or combo. |
### Tabs
| Tool | Purpose |
| --- | --- |
| `browser_tab_list` | List visible user and Agent Window tabs. |
| `browser_tab_create` | Create an Agent Window tab. |
| `browser_tab_select` | Focus an Agent Window tab. |
| `browser_tab_close` | Close an Agent Window tab. |
| `browser_tab_borrow` | Move a user tab into the Agent Window. |
| `browser_tab_return` | Return a borrowed tab to its original window. |
### Assistance, diagnostics, and display
| Tool | Purpose |
| --- | --- |
| `browser_request_help` | Pause for an explicit user browser step. |
| `browser_console` | Read buffered console and exception entries. |
| `browser_network` | Read buffered response and failure metadata. |
| `browser_window_resize` | Resize the Agent Window. |
| `browser_emulate` | Apply or clear per-tab device emulation. |
## Error handling and final checklist
On a stale ref, observe again and retry once with a fresh ref. On a closed or unknown tab, list
tabs instead of guessing. On an unknown session, list owned sessions or start a new one; never try
foreign ids. On timeout, decide whether one longer, purposeful wait is justified. Avoid repeated
actions with unchanged state.
Before finishing a browser task, verify that:
- The user's observable goal is met, or the blocker is clearly reported.
- Any borrowed tab has been returned when practical.
- The owned session has been stopped unless the user explicitly asked to keep it open.
- No further page action is taken after success.
@@ -1,10 +0,0 @@
---
## DSH routing reminder
The CLI-oriented reference above describes behavior, not the execution mechanism in
DeepSeek Harness. For every browser operation, call the injected `browser_*` model
tool. Do not translate these examples into `bash` commands and do not execute `bsk`
directly; the plugin invokes `bsk` internally. In particular, always begin with
`browser_session_start`, pass its returned session id to subsequent `browser_*`
tools, and finish with `browser_session_stop`.
@@ -1,71 +0,0 @@
# browser-skill (dsh edition)
## DSH tool-routing requirement (highest priority)
This skill is running inside DeepSeek Harness, where the plugin has injected
structured `browser_*` model tools. **Always call those injected `browser_*` tools
for browser work. Never use `bash`, `shell`, `exec`, or another tool to invoke the
`bsk` CLI directly.** The CLI command names shown below are only conceptual
mappings for understanding the tools; they are not instructions to run commands.
If a `browser_*` tool is available, using the CLI directly is incorrect because it
bypasses plugin session ownership, observation UI, tool cards, and cleanup.
Use the injected tool whose name matches the operation, for example:
`browser_session_start`, `browser_navigate`, `browser_observe`, `browser_snapshot`,
`browser_click`, `browser_fill`, `browser_press`, `browser_screenshot`, and
`browser_session_stop`. Pass arguments as the tool schema specifies (not CLI flags).
Drive the user's **real Chromium browser** (logins, cookies, Agent Window isolation)
through this plugin's `browser_*` tools. Each injected tool is implemented by the
plugin using the `bsk` engine internally — the model must not invoke that engine
itself. The workflow, judgment rules, and safety constraints remain identical to
the canonical CLI skill.
> **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
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.
---
@@ -1,6 +1,6 @@
/** /**
* Lazy tool-schema injection ("progressive disclosure", final stage): with * Lazy tool-schema injection ("progressive disclosure", final stage): with
* `lazyTools` on (the default), the 11 browser_* tool schemas stay OUT of the * `lazyTools` on (the default), the 28 browser_* tool schemas stay OUT of the
* system prompt until the `browser-skill` skill has actually been invoked — * system prompt until the `browser-skill` skill has actually been invoked —
* the skill catalog entry is the only advertisement. One successful * the skill catalog entry is the only advertisement. One successful
* invocation (model tool call, or a user's `/browser-skill` gesture) reveals * invocation (model tool call, or a user's `/browser-skill` gesture) reveals
@@ -96,7 +96,7 @@ export function hasSuccessfulSkillInvocation(events: readonly SessionEventLike[]
/** /**
* Arm the lazy reveal. Returns a disposer tearing down listeners and — when * Arm the lazy reveal. Returns a disposer tearing down listeners and — when
* the reveal already happened — the tool suite itself. * the reveal already happened — the tool suite itself.
* @param registerSuite - registers the 11 browser_* tools, returns their disposer. * @param registerSuite - registers the 28 browser_* tools, returns their disposer.
*/ */
export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void { export function armLazyTools(ctx: Context, registerSuite: () => () => void): () => void {
let suiteDisposer: (() => void) | undefined; let suiteDisposer: (() => void) | undefined;
@@ -439,14 +439,48 @@ export function actionForLabel(label: string): string {
return "observing"; return "observing";
case "click": case "click":
return "clicking"; return "clicking";
case "hover":
return "hovering";
case "fill": case "fill":
return "filling"; return "filling";
case "select":
return "selecting";
case "press": case "press":
return "pressing"; return "pressing";
case "screenshot": case "screenshot":
return "capturing"; return "capturing";
case "emulate": case "emulate":
return "emulating"; return "emulating";
case "tab list":
return "listing tabs";
case "tab create":
return "creating tab";
case "tab close":
return "closing tab";
case "tab select":
return "selecting tab";
case "tab borrow":
return "borrowing tab";
case "tab return":
return "returning tab";
case "navigate-back":
return "navigating back";
case "navigate-forward":
return "navigating forward";
case "reload":
return "reloading";
case "wait-for-navigation":
return "waiting for navigation";
case "request-help":
return "waiting for user";
case "get-html":
return "reading HTML";
case "console":
return "reading console";
case "network":
return "reading network";
case "window resize":
return "resizing window";
default: default:
return label; return label;
} }
@@ -0,0 +1,58 @@
import type { ToolDefinition, ToolResult, ToolRunContext } from "@deepseek-ai/dsh-tools";
import type { ToolDeps } from "./tools";
export type ToolRegistrar = (definition: ToolDefinition) => () => void;
type TerminalPresentation = { card: "terminal"; output: string; exitCode: number } | undefined;
/** Existing tool runtime seams reused by the phase-one capability modules. */
export interface PhaseOneRuntime {
run(
exec: ToolRunContext,
args: string[],
label: string,
observeSession?: string,
runnerTimeoutMs?: number,
): Promise<unknown>;
commandLine(args: string[]): string;
presentTerminalResult: (_args: never, result: ToolResult) => TerminalPresentation;
}
export function requireNonEmpty(value: string, name: string): void {
if (value.trim().length === 0) throw new Error(`${name} must be a non-empty string`);
}
export function requirePositive(value: number | undefined, name: string): void {
if (value !== undefined && value <= 0) throw new Error(`${name} must be greater than zero`);
}
/** CLI positional target detection is shared by hover/select/request-help. */
export function isSnapshotRef(target: string): boolean {
return /^@?e\d+$/.test(target);
}
/**
* Give the child enough time to honour a command-level timeout plus IPC
* settlement slack, without shortening the plugin's configured default.
*/
export function runnerTimeout(deps: ToolDeps, commandTimeoutMs: number | undefined): number {
if (commandTimeoutMs === undefined) return deps.config.defaultTimeoutMs;
return Math.max(deps.config.defaultTimeoutMs, commandTimeoutMs + 15_000);
}
export function appendTarget(args: string[], target: string): void {
args.push(target);
}
export function appendTabId(args: string[], tabId: number | undefined): void {
if (tabId !== undefined) args.push("--tab-id", String(tabId));
}
export function appendWaitOptions(
args: string[],
waitUntil: string | undefined,
timeoutMs: number | undefined,
): void {
if (waitUntil !== undefined) args.push("--wait-until", waitUntil);
if (timeoutMs !== undefined) args.push("--timeout", `${timeoutMs}ms`);
}
@@ -0,0 +1,197 @@
import { defineTool } from "@deepseek-ai/dsh-tools";
import {
appendTabId,
appendTarget,
type PhaseOneRuntime,
requireNonEmpty,
requirePositive,
runnerTimeout,
type ToolRegistrar,
} from "./phase-one-runtime";
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM } from "./tool-params";
import type { ToolDeps } from "./tools";
const MODIFIERS = ["alt", "ctrl", "meta", "shift"] as const;
/** Add hover and select without bypassing session ownership or observation. */
export function registerPhaseOneInteractionTools(
deps: ToolDeps,
register: ToolRegistrar,
runtime: PhaseOneRuntime,
): void {
const { registry } = deps;
register(
defineTool({
name: "browser_hover",
description:
"Move the mouse over a snapshot ref or CSS selector to reveal hover-triggered UI. " +
"Run browser_observe or browser_snapshot again afterwards to discover newly visible refs.",
parameters: {
target: {
type: "string",
required: true,
description: "Snapshot ref (@e3 / e3) or CSS selector of the element to hover.",
},
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
modifiers: {
type: "array",
items: { type: "string", enum: MODIFIERS },
description: "Keyboard modifiers held during the mouse move.",
},
settleMs: {
type: "integer",
description: "Milliseconds to wait for hover-triggered UI to settle (default: 200).",
},
timeoutMs: TIMEOUT_MS_PARAM,
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
x: { type: "number", required: true },
y: { type: "number", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text: `[session ${value.session}] hovered at (${value.x}, ${value.y}) on tab ${value.tabId}`,
},
],
},
async execute(args, exec) {
requireNonEmpty(args.target, "target");
requirePositive(args.timeoutMs, "timeoutMs");
requirePositive(args.settleMs, "settleMs");
const sessionId = registry.resolve(args.session, "browser_hover");
const cmdArgs = ["hover", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
if (args.modifiers !== undefined && args.modifiers.length > 0) {
cmdArgs.push("--modifiers", args.modifiers.join(","));
}
if (args.settleMs !== undefined) cmdArgs.push("--settle", `${args.settleMs}ms`);
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
appendTarget(cmdArgs, args.target);
const reply = (await runtime.run(
exec,
cmdArgs,
"hover",
sessionId,
runnerTimeout(deps, args.timeoutMs),
)) as { tab_id: number; x: number; y: number };
return { session: sessionId, tabId: reply.tab_id, x: reply.x, y: reply.y };
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"hover",
args.target,
"--session",
args.session ?? "(current)",
]),
description: "Hover an element",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_select",
description:
"Set a select element's option values. Pass one value for a normal select or multiple " +
"values for a multi-select; values replace the current selection.",
parameters: {
target: {
type: "string",
required: true,
description: "Snapshot ref (@e3 / e3) or CSS selector of the select element.",
},
values: {
type: "array",
required: true,
items: { type: "string" },
description: "Option value attributes to select; at least one value is required.",
},
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
timeoutMs: TIMEOUT_MS_PARAM,
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
multiple: { type: "boolean", required: true },
selectedValues: {
type: "array",
required: true,
items: { type: "string" },
},
selectedLabels: {
type: "array",
required: true,
items: { type: "string" },
},
},
},
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] selected ` +
`${value.selectedValues.join(", ") || "(none)"} on tab ${value.tabId}`,
},
],
},
async execute(args, exec) {
requireNonEmpty(args.target, "target");
requirePositive(args.timeoutMs, "timeoutMs");
if (args.values.length === 0) throw new Error("values must contain at least one option");
const sessionId = registry.resolve(args.session, "browser_select");
const cmdArgs = ["select", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
for (const value of args.values) cmdArgs.push("--value", value);
if (args.timeoutMs !== undefined) cmdArgs.push("--timeout", `${args.timeoutMs}ms`);
appendTarget(cmdArgs, args.target);
const reply = (await runtime.run(
exec,
cmdArgs,
"select",
sessionId,
runnerTimeout(deps, args.timeoutMs),
)) as {
tab_id: number;
multiple: boolean;
selected_values: string[];
selected_labels: string[];
};
return {
session: sessionId,
tabId: reply.tab_id,
multiple: reply.multiple,
selectedValues: reply.selected_values,
selectedLabels: reply.selected_labels,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"select",
args.target,
"--session",
args.session ?? "(current)",
]),
description: `Select ${args.values.length} option${args.values.length === 1 ? "" : "s"}`,
}),
presentResult: runtime.presentTerminalResult,
}),
);
}
@@ -0,0 +1,230 @@
import { defineTool } from "@deepseek-ai/dsh-tools";
import {
appendTabId,
appendWaitOptions,
type PhaseOneRuntime,
requirePositive,
runnerTimeout,
type ToolRegistrar,
} from "./phase-one-runtime";
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM, WAIT_UNTIL_PARAM } from "./tool-params";
import type { ToolDeps } from "./tools";
interface RawHistoryResult {
tab_id: number;
previous_url?: string;
final_url?: string;
reached: string;
error_text?: string;
}
const HISTORY_OUTPUT = {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
previousUrl: { type: "string" },
finalUrl: { type: "string" },
reached: { type: "string", required: true },
errorText: { type: "string" },
},
} as const;
function mapHistory(sessionId: string, reply: RawHistoryResult) {
return {
session: sessionId,
tabId: reply.tab_id,
...(reply.previous_url !== undefined ? { previousUrl: reply.previous_url } : {}),
...(reply.final_url !== undefined ? { finalUrl: reply.final_url } : {}),
reached: reply.reached,
...(reply.error_text !== undefined ? { errorText: reply.error_text } : {}),
};
}
/** Add browser history, reload, and explicit lifecycle waiting. */
export function registerPhaseOneNavigationTools(
deps: ToolDeps,
register: ToolRegistrar,
runtime: PhaseOneRuntime,
): void {
const { registry } = deps;
const registerHistory = (direction: "back" | "forward") => {
const toolName = `browser_navigate_${direction}` as const;
const command = `navigate-${direction}`;
register(
defineTool({
name: toolName,
description: `Navigate the active Agent Window tab ${direction} by one history entry.`,
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
waitUntil: WAIT_UNTIL_PARAM,
timeoutMs: TIMEOUT_MS_PARAM,
},
output: {
schema: HISTORY_OUTPUT,
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] navigated ${direction} on tab ${value.tabId}` +
(value.finalUrl !== undefined ? ` to ${value.finalUrl}` : "") +
` (reached: ${value.reached})` +
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
},
],
},
async execute(args, exec) {
requirePositive(args.timeoutMs, "timeoutMs");
const sessionId = registry.resolve(args.session, toolName);
const cmdArgs = [command, "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
const reply = (await runtime.run(
exec,
cmdArgs,
command,
sessionId,
runnerTimeout(deps, args.timeoutMs),
)) as RawHistoryResult;
if (reply.final_url !== undefined) deps.observation.setUrl(sessionId, reply.final_url);
return mapHistory(sessionId, reply);
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([command, "--session", args.session ?? "(current)"]),
description: `Navigate ${direction}`,
}),
presentResult: runtime.presentTerminalResult,
}),
);
};
registerHistory("back");
registerHistory("forward");
register(
defineTool({
name: "browser_reload",
description:
"Reload the active Agent Window tab and wait for a lifecycle phase. Set hard=true to " +
"bypass the HTTP cache.",
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
waitUntil: WAIT_UNTIL_PARAM,
timeoutMs: TIMEOUT_MS_PARAM,
hard: { type: "boolean", description: "Bypass the HTTP cache while reloading." },
},
output: {
schema: HISTORY_OUTPUT,
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] reloaded tab ${value.tabId}` +
(value.finalUrl !== undefined ? ` at ${value.finalUrl}` : "") +
` (reached: ${value.reached})` +
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
},
],
},
async execute(args, exec) {
requirePositive(args.timeoutMs, "timeoutMs");
const sessionId = registry.resolve(args.session, "browser_reload");
const cmdArgs = ["reload", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
appendWaitOptions(cmdArgs, args.waitUntil, args.timeoutMs);
if (args.hard === true) cmdArgs.push("--hard");
const reply = (await runtime.run(
exec,
cmdArgs,
"reload",
sessionId,
runnerTimeout(deps, args.timeoutMs),
)) as RawHistoryResult;
if (reply.final_url !== undefined) deps.observation.setUrl(sessionId, reply.final_url);
return mapHistory(sessionId, reply);
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"reload",
"--session",
args.session ?? "(current)",
...(args.hard === true ? ["--hard"] : []),
]),
description: "Reload the active tab",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_wait_for_navigation",
description:
"Wait for a page lifecycle event on the active Agent Window tab. Use after an action " +
"that may navigate when the action result itself does not wait for navigation.",
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
waitUntil: WAIT_UNTIL_PARAM,
timeoutMs: TIMEOUT_MS_PARAM,
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
reached: { type: "string", required: true },
errorText: { type: "string" },
},
},
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] navigation wait on tab ${value.tabId}: ${value.reached}` +
(value.errorText !== undefined ? ` — ${value.errorText}` : ""),
},
],
},
async execute(args, exec) {
requirePositive(args.timeoutMs, "timeoutMs");
const sessionId = registry.resolve(args.session, "browser_wait_for_navigation");
const timeoutMs = args.timeoutMs ?? 30_000;
const cmdArgs = ["wait-for-navigation", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
if (args.waitUntil !== undefined) cmdArgs.push("--wait-until", args.waitUntil);
cmdArgs.push("--timeout", `${timeoutMs}ms`);
const reply = (await runtime.run(
exec,
cmdArgs,
"wait-for-navigation",
sessionId,
runnerTimeout(deps, timeoutMs),
)) as { tab_id: number; reached: string; error_text?: string };
return {
session: sessionId,
tabId: reply.tab_id,
reached: reply.reached,
...(reply.error_text !== undefined ? { errorText: reply.error_text } : {}),
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"wait-for-navigation",
"--session",
args.session ?? "(current)",
]),
description: "Wait for page navigation",
}),
presentResult: runtime.presentTerminalResult,
}),
);
}
@@ -0,0 +1,672 @@
import { defineTool } from "@deepseek-ai/dsh-tools";
import {
appendTabId,
isSnapshotRef,
type PhaseOneRuntime,
requireNonEmpty,
requirePositive,
runnerTimeout,
type ToolRegistrar,
} from "./phase-one-runtime";
import { SESSION_PARAM, TAB_ID_PARAM, TIMEOUT_MS_PARAM } from "./tool-params";
import type { ToolDeps } from "./tools";
interface HelpConditionInput {
urlContains?: string;
urlMatches?: string;
selectorExists?: string;
selectorMissing?: string;
textExists?: string;
textMissing?: string;
}
interface CompletionCriteriaInput {
any?: HelpConditionInput[];
all?: HelpConditionInput[];
stableForMs?: number;
}
interface RawConsoleFrame {
function_name?: string;
url?: string;
line?: number;
column?: number;
}
interface RawConsoleEntry {
sequence: number;
kind: string;
level: string;
text: string;
url?: string;
line?: number;
column?: number;
timestamp?: number;
stack_trace?: RawConsoleFrame[];
truncated?: boolean;
}
interface RawNetworkEntry {
sequence: number;
kind: "response" | "failure";
method?: string;
url?: string;
status?: number;
status_text?: string;
mime_type?: string;
resource_type?: string;
error_text?: string;
timestamp?: number;
truncated?: boolean;
}
const HELP_CONDITION_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
urlContains: { type: "string" },
urlMatches: { type: "string" },
selectorExists: { type: "string" },
selectorMissing: { type: "string" },
textExists: { type: "string" },
textMissing: { type: "string" },
},
} as const;
const DEBUG_PARAMETERS = {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
since: {
type: "integer",
description: "Return entries with a sequence strictly greater than this cursor.",
},
limit: {
type: "integer",
description: "Maximum entries to return (default: 50, daemon cap: 200).",
},
maxTextChars: {
type: "integer",
description: "Maximum characters per entry text/URL (default: 1000, daemon cap: 4096).",
},
} as const;
function mapCondition(condition: HelpConditionInput) {
return {
...(condition.urlContains !== undefined ? { url_contains: condition.urlContains } : {}),
...(condition.urlMatches !== undefined ? { url_matches: condition.urlMatches } : {}),
...(condition.selectorExists !== undefined
? { selector_exists: condition.selectorExists }
: {}),
...(condition.selectorMissing !== undefined
? { selector_missing: condition.selectorMissing }
: {}),
...(condition.textExists !== undefined ? { text_exists: condition.textExists } : {}),
...(condition.textMissing !== undefined ? { text_missing: condition.textMissing } : {}),
};
}
function mapCompletionCriteria(criteria: CompletionCriteriaInput) {
return {
...(criteria.any !== undefined ? { any: criteria.any.map(mapCondition) } : {}),
...(criteria.all !== undefined ? { all: criteria.all.map(mapCondition) } : {}),
...(criteria.stableForMs !== undefined ? { stable_for_ms: criteria.stableForMs } : {}),
};
}
function validateDebugArgs(args: { since?: number; limit?: number; maxTextChars?: number }): void {
if (args.since !== undefined && args.since < 0) {
throw new Error("since must be zero or greater");
}
requirePositive(args.limit, "limit");
requirePositive(args.maxTextChars, "maxTextChars");
}
function appendDebugOptions(
args: string[],
options: { tabId?: number; since?: number; limit?: number; maxTextChars?: number },
): void {
appendTabId(args, options.tabId);
if (options.since !== undefined) args.push("--since", String(options.since));
if (options.limit !== undefined) args.push("--limit", String(options.limit));
if (options.maxTextChars !== undefined) {
args.push("--max-text-chars", String(options.maxTextChars));
}
}
/** Human help, raw HTML, diagnostics, and Agent Window sizing. */
export function registerPhaseOneSupportTools(
deps: ToolDeps,
register: ToolRegistrar,
runtime: PhaseOneRuntime,
): void {
const { registry } = deps;
register(
defineTool({
name: "browser_request_help",
description:
"Pause browser automation and ask the user to complete an in-page step such as login, " +
"captcha, OTP, or confirmation. After continued/completed, observe again before using refs.",
parameters: {
prompt: {
type: "string",
required: true,
description: "Clear instructions shown to the user in the browser help overlay.",
},
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
title: { type: "string", description: "Optional title for the help overlay." },
targets: {
type: "array",
items: { type: "string" },
description: "Snapshot refs or CSS selectors to scroll to and highlight for the user.",
},
timeoutMs: {
...TIMEOUT_MS_PARAM,
description: "Maximum wait for the user in milliseconds (default: 300000).",
},
completionCriteria: {
type: "object",
additionalProperties: false,
description: "Optional explicit success detector for automatic completion.",
properties: {
any: { type: "array", items: HELP_CONDITION_SCHEMA },
all: { type: "array", items: HELP_CONDITION_SCHEMA },
stableForMs: {
type: "integer",
description: "How long criteria must remain true before completing.",
},
},
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
outcome: {
type: "string",
required: true,
enum: ["continued", "cancelled", "timed_out", "completed", "navigated", "disabled"],
},
completedBy: { type: "string" },
note: { type: "string" },
resolvedTargets: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
matched: { type: "boolean", required: true },
ref: { type: "string" },
selector: { type: "string" },
},
},
},
},
},
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] user help outcome on tab ${value.tabId}: ${value.outcome}` +
(value.note !== undefined ? ` — ${value.note}` : "") +
(value.resolvedTargets !== undefined
? `\ntargets: ${value.resolvedTargets
.map(
(target) =>
`${target.ref ?? target.selector ?? "(unknown)"}=${target.matched ? "matched" : "not found"}`,
)
.join(", ")}`
: ""),
},
],
},
async execute(args, exec) {
requireNonEmpty(args.prompt, "prompt");
if (args.title !== undefined) requireNonEmpty(args.title, "title");
requirePositive(args.timeoutMs, "timeoutMs");
for (const target of args.targets ?? []) requireNonEmpty(target, "target");
const criteria = args.completionCriteria as CompletionCriteriaInput | undefined;
if (criteria?.stableForMs !== undefined && criteria.stableForMs < 0) {
throw new Error("completionCriteria.stableForMs must be zero or greater");
}
const sessionId = registry.resolve(args.session, "browser_request_help");
const timeoutMs = args.timeoutMs ?? 300_000;
const cmdArgs = [
"request-help",
"--session",
sessionId,
"--prompt",
args.prompt,
"--timeout",
`${timeoutMs}ms`,
];
appendTabId(cmdArgs, args.tabId);
if (args.title !== undefined) cmdArgs.push("--title", args.title);
for (const target of args.targets ?? []) cmdArgs.push("--target", target);
if (criteria !== undefined) {
cmdArgs.push("--completion-criteria", JSON.stringify(mapCompletionCriteria(criteria)));
}
const reply = (await runtime.run(
exec,
cmdArgs,
"request-help",
sessionId,
runnerTimeout(deps, timeoutMs),
)) as {
outcome: "continued" | "cancelled" | "timed_out" | "completed" | "navigated" | "disabled";
completed_by?: string;
note?: string;
tab_id: number;
resolved_targets?: { matched: boolean; ref?: string; selector?: string }[];
};
return {
session: sessionId,
tabId: reply.tab_id,
outcome: reply.outcome,
...(reply.completed_by !== undefined ? { completedBy: reply.completed_by } : {}),
...(reply.note !== undefined ? { note: reply.note } : {}),
...(reply.resolved_targets !== undefined
? { resolvedTargets: reply.resolved_targets }
: {}),
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine(["request-help", "--session", args.session ?? "(current)"]),
description: "Ask the user for browser help",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_get_html",
description:
"Read raw DOM HTML from the active Agent Window tab, optionally scoped to a fresh " +
"snapshot ref. Use only when browser_observe/browser_snapshot cannot answer the question.",
parameters: {
session: SESSION_PARAM,
tabId: TAB_ID_PARAM,
ref: { type: "string", description: "Fresh snapshot ref that scopes the HTML subtree." },
maxBytes: {
type: "integer",
description: "Maximum returned HTML bytes before truncation (default: 524288).",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
html: { type: "string", required: true },
truncated: { type: "boolean", required: true },
byteSize: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text:
value.html +
(value.truncated ? `\n(HTML truncated; original size ${value.byteSize} bytes)` : ""),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
if (args.ref !== undefined) {
requireNonEmpty(args.ref, "ref");
if (!isSnapshotRef(args.ref)) throw new Error("ref must be a snapshot ref such as @e3");
}
requirePositive(args.maxBytes, "maxBytes");
const sessionId = registry.resolve(args.session, "browser_get_html");
const cmdArgs = ["get-html", "--session", sessionId];
appendTabId(cmdArgs, args.tabId);
if (args.ref !== undefined) cmdArgs.push("--ref", args.ref);
if (args.maxBytes !== undefined) cmdArgs.push("--max-bytes", String(args.maxBytes));
const reply = (await runtime.run(exec, cmdArgs, "get-html", sessionId)) as {
tab_id: number;
html: string;
truncated?: boolean;
byte_size: number;
};
return {
session: sessionId,
tabId: reply.tab_id,
html: reply.html,
truncated: reply.truncated ?? false,
byteSize: reply.byte_size,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"get-html",
"--session",
args.session ?? "(current)",
...(args.ref !== undefined ? ["--ref", args.ref] : []),
]),
description: "Read raw page HTML",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_console",
description:
"Read buffered console messages, browser log entries, and JavaScript exceptions from a tab. " +
"This is read-only and does not evaluate JavaScript.",
parameters: {
...DEBUG_PARAMETERS,
includeStack: {
type: "boolean",
description: "Include structured stack frames in returned console entries.",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
entries: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
sequence: { type: "integer", required: true },
kind: { type: "string", required: true },
level: { type: "string", required: true },
text: { type: "string", required: true },
url: { type: "string" },
line: { type: "integer" },
column: { type: "integer" },
timestamp: { type: "number" },
stackTrace: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
functionName: { type: "string" },
url: { type: "string" },
line: { type: "integer" },
column: { type: "integer" },
},
},
},
truncated: { type: "boolean", required: true },
},
},
},
nextSince: { type: "integer", required: true },
truncated: { type: "boolean", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text:
value.entries.length === 0
? "(no console messages captured)"
: value.entries
.map((entry) => {
const location =
entry.url === undefined
? ""
: ` ${entry.url}${entry.line !== undefined ? `:${entry.line}` : ""}${entry.column !== undefined ? `:${entry.column}` : ""}`;
const stack = entry.stackTrace
.map(
(frame) =>
`\n at ${frame.functionName ?? "<anonymous>"} ${frame.url ?? ""}` +
`${frame.line !== undefined ? `:${frame.line}` : ""}` +
`${frame.column !== undefined ? `:${frame.column}` : ""}`,
)
.join("");
return `#${entry.sequence} ${entry.level} ${entry.kind}${location} ${entry.text}${stack}`;
})
.join("\n") +
(value.truncated
? `\n(output truncated; continue with since=${value.nextSince})`
: ""),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
validateDebugArgs(args);
const sessionId = registry.resolve(args.session, "browser_console");
const cmdArgs = ["console", "--session", sessionId];
appendDebugOptions(cmdArgs, args);
if (args.includeStack === true) cmdArgs.push("--include-stack");
const reply = (await runtime.run(exec, cmdArgs, "console", sessionId)) as {
tab_id: number;
entries?: RawConsoleEntry[];
next_since: number;
truncated?: boolean;
};
return {
session: sessionId,
tabId: reply.tab_id,
entries: (reply.entries ?? []).map((entry) => ({
sequence: entry.sequence,
kind: entry.kind,
level: entry.level,
text: entry.text,
...(entry.url !== undefined ? { url: entry.url } : {}),
...(entry.line !== undefined ? { line: entry.line } : {}),
...(entry.column !== undefined ? { column: entry.column } : {}),
...(entry.timestamp !== undefined ? { timestamp: entry.timestamp } : {}),
stackTrace: (entry.stack_trace ?? []).map((frame) => ({
...(frame.function_name !== undefined ? { functionName: frame.function_name } : {}),
...(frame.url !== undefined ? { url: frame.url } : {}),
...(frame.line !== undefined ? { line: frame.line } : {}),
...(frame.column !== undefined ? { column: frame.column } : {}),
})),
truncated: entry.truncated ?? false,
})),
nextSince: reply.next_since,
truncated: reply.truncated ?? false,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine(["console", "--session", args.session ?? "(current)"]),
description: "Read browser console messages",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_network",
description:
"Read buffered network responses and failures from a tab. Returns metadata only; request " +
"and response headers and bodies are not captured.",
parameters: DEBUG_PARAMETERS,
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
entries: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
sequence: { type: "integer", required: true },
kind: { type: "string", required: true, enum: ["response", "failure"] },
method: { type: "string" },
url: { type: "string" },
status: { type: "integer" },
statusText: { type: "string" },
mimeType: { type: "string" },
resourceType: { type: "string" },
errorText: { type: "string" },
timestamp: { type: "number" },
truncated: { type: "boolean", required: true },
},
},
},
nextSince: { type: "integer", required: true },
truncated: { type: "boolean", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text:
value.entries.length === 0
? "(no network activity captured)"
: value.entries
.map((entry) =>
entry.kind === "failure"
? `#${entry.sequence} FAILED ${entry.method ?? "?"} ${entry.url ?? "(unknown)"} — ${entry.errorText ?? "failed"}`
: `#${entry.sequence} ${entry.status ?? "?"} ${entry.method ?? "?"} ${entry.url ?? "(unknown)"}`,
)
.join("\n") +
(value.truncated
? `\n(output truncated; continue with since=${value.nextSince})`
: ""),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
validateDebugArgs(args);
const sessionId = registry.resolve(args.session, "browser_network");
const cmdArgs = ["network", "--session", sessionId];
appendDebugOptions(cmdArgs, args);
const reply = (await runtime.run(exec, cmdArgs, "network", sessionId)) as {
tab_id: number;
entries?: RawNetworkEntry[];
next_since: number;
truncated?: boolean;
};
return {
session: sessionId,
tabId: reply.tab_id,
entries: (reply.entries ?? []).map((entry) => ({
sequence: entry.sequence,
kind: entry.kind,
...(entry.method !== undefined ? { method: entry.method } : {}),
...(entry.url !== undefined ? { url: entry.url } : {}),
...(entry.status !== undefined ? { status: entry.status } : {}),
...(entry.status_text !== undefined ? { statusText: entry.status_text } : {}),
...(entry.mime_type !== undefined ? { mimeType: entry.mime_type } : {}),
...(entry.resource_type !== undefined ? { resourceType: entry.resource_type } : {}),
...(entry.error_text !== undefined ? { errorText: entry.error_text } : {}),
...(entry.timestamp !== undefined ? { timestamp: entry.timestamp } : {}),
truncated: entry.truncated ?? false,
})),
nextSince: reply.next_since,
truncated: reply.truncated ?? false,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine(["network", "--session", args.session ?? "(current)"]),
description: "Read browser network activity",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_window_resize",
description: "Resize the owned session's Agent Window using outer CSS-pixel dimensions.",
parameters: {
width: {
type: "integer",
required: true,
description: "Agent Window outer width in CSS pixels (100..=7680).",
},
height: {
type: "integer",
required: true,
description: "Agent Window outer height in CSS pixels (100..=7680).",
},
session: SESSION_PARAM,
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
windowId: { type: "integer", required: true },
width: { type: "integer", required: true },
height: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text: `[session ${value.session}] resized window ${value.windowId} to ${value.width}x${value.height}`,
},
],
},
async execute(args, exec) {
if (args.width < 100 || args.width > 7680 || args.height < 100 || args.height > 7680) {
throw new Error("width and height must each be in the range 100..=7680");
}
const sessionId = registry.resolve(args.session, "browser_window_resize");
const reply = (await runtime.run(
exec,
[
"window",
"resize",
"--session",
sessionId,
"--width",
String(args.width),
"--height",
String(args.height),
],
"window resize",
sessionId,
)) as { window_id: number; width: number; height: number };
return {
session: sessionId,
windowId: reply.window_id,
width: reply.width,
height: reply.height,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"window",
"resize",
"--session",
args.session ?? "(current)",
"--width",
String(args.width),
"--height",
String(args.height),
]),
description: "Resize the Agent Window",
}),
presentResult: runtime.presentTerminalResult,
}),
);
}
@@ -0,0 +1,402 @@
import { defineTool } from "@deepseek-ai/dsh-tools";
import type { PhaseOneRuntime, ToolRegistrar } from "./phase-one-runtime";
import { requireNonEmpty } from "./phase-one-runtime";
import { SESSION_PARAM } from "./tool-params";
import type { ToolDeps } from "./tools";
const TAB_ID_REQUIRED = {
type: "integer",
required: true,
description: "Chrome tab id returned by browser_tab_list or browser_tab_create.",
} as const;
interface RawTabInfo {
tab_id: number;
title?: string;
url?: string;
window_id?: number;
active?: boolean;
scope?: "user" | "agent";
}
/** Add Agent Window tab management while preserving the owned-session boundary. */
export function registerPhaseOneTabTools(
deps: ToolDeps,
register: ToolRegistrar,
runtime: PhaseOneRuntime,
): void {
const { registry } = deps;
register(
defineTool({
name: "browser_tab_list",
description:
"List tabs visible to an owned browser session. Other sessions' Agent Windows remain " +
"hidden; use scope=user to find a user tab before borrowing it.",
parameters: {
session: SESSION_PARAM,
scope: {
type: "string",
enum: ["user", "agent", "all"],
description: "Which tabs to list (default: all).",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabs: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
tabId: { type: "integer", required: true },
title: { type: "string" },
url: { type: "string" },
windowId: { type: "integer" },
active: { type: "boolean" },
scope: { type: "string", enum: ["user", "agent"] },
},
},
},
},
},
render: (_args, value) => [
{
type: "text",
text:
value.tabs.length === 0
? `[session ${value.session}] no matching tabs`
: value.tabs
.map(
(tab) =>
`${tab.tabId} [${tab.scope ?? "unknown"}]${tab.active === true ? " [active]" : ""} ` +
`${tab.title ?? "(untitled)"} — ${tab.url ?? "(unknown URL)"}`,
)
.join("\n"),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tab_list");
const cmdArgs = ["tab", "list", "--session", sessionId];
if (args.scope !== undefined) cmdArgs.push("--scope", args.scope);
const reply = (await runtime.run(exec, cmdArgs, "tab list", sessionId)) as {
tabs: RawTabInfo[];
};
return {
session: sessionId,
tabs: reply.tabs.map((tab) => ({
tabId: tab.tab_id,
...(tab.title !== undefined ? { title: tab.title } : {}),
...(tab.url !== undefined ? { url: tab.url } : {}),
...(tab.window_id !== undefined ? { windowId: tab.window_id } : {}),
...(tab.active !== undefined ? { active: tab.active } : {}),
...(tab.scope !== undefined ? { scope: tab.scope } : {}),
})),
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"tab",
"list",
"--session",
args.session ?? "(current)",
...(args.scope !== undefined ? ["--scope", args.scope] : []),
]),
description: "List browser tabs",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_tab_create",
description:
"Create a tab inside the owned session's Agent Window. The tab is focused by default; " +
"set active=false to open it in the background.",
parameters: {
session: SESSION_PARAM,
url: { type: "string", description: "Initial URL (default: chrome://newtab/)." },
active: { type: "boolean", description: "Whether to focus the new tab (default: true)." },
index: { type: "integer", description: "Insertion index in the Agent Window tab strip." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
windowId: { type: "integer", required: true },
url: { type: "string", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text: `[session ${value.session}] created tab ${value.tabId} in window ${value.windowId} (${value.url || "pending URL"})`,
},
],
},
async execute(args, exec) {
if (args.url !== undefined) requireNonEmpty(args.url, "url");
const sessionId = registry.resolve(args.session, "browser_tab_create");
const cmdArgs = ["tab", "create", "--session", sessionId];
if (args.url !== undefined) cmdArgs.push("--url", args.url);
if (args.active === false) cmdArgs.push("--no-active");
if (args.index !== undefined) cmdArgs.push("--index", String(args.index));
const reply = (await runtime.run(exec, cmdArgs, "tab create", sessionId)) as {
tab_id: number;
window_id: number;
url: string;
};
if (args.active !== false && reply.url.length > 0) {
deps.observation.setUrl(sessionId, reply.url);
}
return {
session: sessionId,
tabId: reply.tab_id,
windowId: reply.window_id,
url: reply.url,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"tab",
"create",
"--session",
args.session ?? "(current)",
...(args.url !== undefined ? ["--url", args.url] : []),
]),
description: "Create an Agent Window tab",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_tab_close",
description: "Close a tab in the owned session's Agent Window.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
},
},
render: (_args, value) => [
{ type: "text", text: `[session ${value.session}] closed tab ${value.tabId}` },
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tab_close");
const reply = (await runtime.run(
exec,
["tab", "close", String(args.tabId), "--session", sessionId],
"tab close",
sessionId,
)) as { tab_id: number };
return { session: sessionId, tabId: reply.tab_id };
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"tab",
"close",
String(args.tabId),
"--session",
args.session ?? "(current)",
]),
description: "Close an Agent Window tab",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_tab_select",
description: "Focus a tab in the owned session's Agent Window.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
windowId: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text: `[session ${value.session}] selected tab ${value.tabId} in window ${value.windowId}`,
},
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tab_select");
const reply = (await runtime.run(
exec,
["tab", "select", String(args.tabId), "--session", sessionId],
"tab select",
sessionId,
)) as { tab_id: number; window_id: number };
return { session: sessionId, tabId: reply.tab_id, windowId: reply.window_id };
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"tab",
"select",
String(args.tabId),
"--session",
args.session ?? "(current)",
]),
description: "Select an Agent Window tab",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_tab_borrow",
description:
"Move a user-window tab into the owned session's Agent Window for controlled interaction. " +
"Return it with browser_tab_return when finished; stopping the session also auto-returns it.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
originalWindowId: { type: "integer", required: true },
originalIndex: { type: "integer", required: true },
agentWindowId: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] borrowed tab ${value.tabId} from window ` +
`${value.originalWindowId} into Agent Window ${value.agentWindowId}`,
},
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tab_borrow");
const reply = (await runtime.run(
exec,
["tab", "borrow", String(args.tabId), "--session", sessionId],
"tab borrow",
sessionId,
)) as {
tab_id: number;
original_window_id: number;
original_index: number;
agent_window_id: number;
};
return {
session: sessionId,
tabId: reply.tab_id,
originalWindowId: reply.original_window_id,
originalIndex: reply.original_index,
agentWindowId: reply.agent_window_id,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"tab",
"borrow",
String(args.tabId),
"--session",
args.session ?? "(current)",
]),
description: "Borrow a user tab",
}),
presentResult: runtime.presentTerminalResult,
}),
);
register(
defineTool({
name: "browser_tab_return",
description: "Return a borrowed tab to its original user window and tab-strip position.",
parameters: { tabId: TAB_ID_REQUIRED, session: SESSION_PARAM },
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
session: { type: "string", required: true },
tabId: { type: "integer", required: true },
returnedToWindowId: { type: "integer", required: true },
returnedToIndex: { type: "integer", required: true },
fallback: { type: "boolean", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text:
`[session ${value.session}] returned tab ${value.tabId} to window ` +
`${value.returnedToWindowId} at index ${value.returnedToIndex}` +
(value.fallback ? " (original window unavailable; used fallback)" : ""),
},
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_tab_return");
const reply = (await runtime.run(
exec,
["tab", "return", String(args.tabId), "--session", sessionId],
"tab return",
sessionId,
)) as {
tab_id: number;
returned_to_window_id: number;
returned_to_index: number;
fallback?: boolean;
};
return {
session: sessionId,
tabId: reply.tab_id,
returnedToWindowId: reply.returned_to_window_id,
returnedToIndex: reply.returned_to_index,
fallback: reply.fallback ?? false,
};
},
presentCall: (args) => ({
card: "terminal",
title: runtime.commandLine([
"tab",
"return",
String(args.tabId),
"--session",
args.session ?? "(current)",
]),
description: "Return a borrowed tab",
}),
presentResult: runtime.presentTerminalResult,
}),
);
}
@@ -0,0 +1,18 @@
import type { PhaseOneRuntime, ToolRegistrar } from "./phase-one-runtime";
import { registerPhaseOneInteractionTools } from "./phase-one-tools-interaction";
import { registerPhaseOneNavigationTools } from "./phase-one-tools-navigation";
import { registerPhaseOneSupportTools } from "./phase-one-tools-support";
import { registerPhaseOneTabTools } from "./phase-one-tools-tabs";
import type { ToolDeps } from "./tools";
/** Register the first DSH capability-parity tranche on the existing runtime. */
export function registerPhaseOneTools(
deps: ToolDeps,
register: ToolRegistrar,
runtime: PhaseOneRuntime,
): void {
registerPhaseOneInteractionTools(deps, register, runtime);
registerPhaseOneTabTools(deps, register, runtime);
registerPhaseOneNavigationTools(deps, register, runtime);
registerPhaseOneSupportTools(deps, register, runtime);
}
@@ -2,10 +2,10 @@
* Progressive disclosure of the BrowserSkill agent skill through the harness's * Progressive disclosure of the BrowserSkill agent skill through the harness's
* official skill seam (`ctx.skills`). The catalog entry (name + description) * official skill seam (`ctx.skills`). The catalog entry (name + description)
* is resident; the body is loaded only when the model invokes the `skill` * 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 * tool. The dedicated DeepSeek Harness markdown is embedded into a static
* prelude + the canonical CLI skill body verbatim), so registration and every * module at build time, so registration and every pre-step catalog snapshot
* pre-step catalog snapshot are pure in-memory reads — no disk, no process, * are pure in-memory reads — no disk, no process, no daemon. The CLI-oriented
* no daemon. * repository skill is intentionally a separate interface.
*/ */
import type { Context } from "@deepseek-ai/cordis"; import type { Context } from "@deepseek-ai/cordis";
@@ -26,7 +26,7 @@ interface SkillsLike {
} }
/** /**
* Publish the BSK skill as an embedded runtime skill. Returns the unregistration * Publish the browser skill as an embedded runtime skill. Returns the unregistration
* disposer. A composition without the `skills` service (or with dsh-tool-skill * 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. * retired) leaves the rest of the plugin unaffected — the no-op is silent.
*/ */
@@ -0,0 +1,24 @@
/** Shared model-facing parameter schemas for browser tools. */
export const SESSION_PARAM = {
type: "string",
description:
"bsk session id to act on; must be one created by this plugin's browser_session_start. " +
"Omit to use the current session (the one most recently started or used).",
} as const;
export const TAB_ID_PARAM = {
type: "integer",
description: "Target tab id. Omit to use the Agent Window's active tab.",
} as const;
export const WAIT_UNTIL_PARAM = {
type: "string",
enum: ["load", "domcontentloaded", "networkidle", "commit"],
description: "Page lifecycle phase to wait for (default: load).",
} as const;
export const TIMEOUT_MS_PARAM = {
type: "integer",
description: "Command timeout in milliseconds; must be greater than zero.",
} as const;
+10 -8
View File
@@ -20,9 +20,11 @@ import {
import { ownerSessionIds } from "./archive-cleanup"; import { ownerSessionIds } from "./archive-cleanup";
import { trySaveScreenshot } from "./image"; import { trySaveScreenshot } from "./image";
import { actionForLabel, type ObservationService } from "./observation"; import { actionForLabel, type ObservationService } from "./observation";
import { registerPhaseOneTools } from "./phase-one-tools";
import type { KeyedExecutor } from "./queue"; import type { KeyedExecutor } from "./queue";
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner"; import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
import type { SessionRegistry } from "./sessions"; import type { SessionRegistry } from "./sessions";
import { SESSION_PARAM } from "./tool-params";
/** Plugin configuration resolved from the Schemastery schema in index.ts. */ /** Plugin configuration resolved from the Schemastery schema in index.ts. */
export interface PluginConfig { export interface PluginConfig {
@@ -62,13 +64,6 @@ const DEVICE_PRESETS = [
"galaxy-tab-s8", "galaxy-tab-s8",
] as const; ] as const;
const SESSION_PARAM = {
type: "string",
description:
"bsk session id to act on; must be one created by this plugin's browser_session_start. " +
"Omit to use the current session (the one most recently started or used).",
} as const;
/** /**
* Run one bsk command and return its parsed JSON payload, or throw. * Run one bsk command and return its parsed JSON payload, or throw.
* `observeSession` marks the run as model-facing work on that session: it is * `observeSession` marks the run as model-facing work on that session: it is
@@ -82,6 +77,7 @@ async function runBsk(
args: string[], args: string[],
label: string, label: string,
observeSession?: string, observeSession?: string,
runnerTimeoutMs?: number,
): Promise<unknown> { ): Promise<unknown> {
// beginAction must fire only when the task actually starts inside the // beginAction must fire only when the task actually starts inside the
// per-session queue — marking it while still queued would overwrite the // per-session queue — marking it while still queued would overwrite the
@@ -98,7 +94,7 @@ async function runBsk(
} }
return deps.runner.run(args, { return deps.runner.run(args, {
signal: exec.signal, signal: exec.signal,
timeoutMs: deps.config.defaultTimeoutMs, timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
...(observeSession !== undefined ? { tag: observeSession } : {}), ...(observeSession !== undefined ? { tag: observeSession } : {}),
}); });
}; };
@@ -959,6 +955,12 @@ export function registerTools(deps: ToolDeps): () => void {
presentResult: presentTerminalResult, presentResult: presentTerminalResult,
}), }),
); );
registerPhaseOneTools(deps, register, {
run: (exec, args, label, observeSession, runnerTimeoutMs) =>
runBsk(deps, exec, args, label, observeSession, runnerTimeoutMs),
commandLine: (args) => cmdline(deps, args),
presentTerminalResult,
});
return () => { return () => {
for (const dispose of disposers.splice(0)) dispose(); for (const dispose of disposers.splice(0)) dispose();
}; };
@@ -649,6 +649,9 @@ describe("actionForLabel", () => {
expect(actionForLabel("navigate")).toBe("navigating"); expect(actionForLabel("navigate")).toBe("navigating");
expect(actionForLabel("screenshot")).toBe("capturing"); expect(actionForLabel("screenshot")).toBe("capturing");
expect(actionForLabel("session start")).toBe("starting"); expect(actionForLabel("session start")).toBe("starting");
expect(actionForLabel("tab borrow")).toBe("borrowing tab");
expect(actionForLabel("request-help")).toBe("waiting for user");
expect(actionForLabel("wait-for-navigation")).toBe("waiting for navigation");
}); });
}); });
@@ -1,4 +1,4 @@
// BSK skill injection: registration wiring, catalog content, progressive-load // Browser skill injection: registration wiring, catalog content, progressive-load
// weight, and silent degradation without the skill seam. // weight, and silent degradation without the skill seam.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -9,7 +9,7 @@ function fakeCtx(skills?: unknown) {
} }
describe("registerBskSkill", () => { describe("registerBskSkill", () => {
it("registers the catalog entry with the assembled body", () => { it("registers the catalog entry with the DSH browser-tool body", () => {
const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = []; const registrations: { skill: Record<string, unknown>; disposer: () => void }[] = [];
const skills = { const skills = {
register(skill: Record<string, unknown>) { register(skill: Record<string, unknown>) {
@@ -26,23 +26,51 @@ describe("registerBskSkill", () => {
expect(skill.name).toBe("browser-skill"); expect(skill.name).toBe("browser-skill");
expect(typeof skill.description).toBe("string"); expect(typeof skill.description).toBe("string");
expect(String(skill.description)).toContain("browser_*"); expect(String(skill.description)).toContain("browser_*");
// body = dsh prelude + canonical CLI skill body + dsh postlude
const content = String(skill.content); const content = String(skill.content);
expect(content).toContain("browser_observe"); const mentionedTools = [...new Set(content.match(/\bbrowser_[a-z][a-z_]*\b/g) ?? [])].sort();
expect(String(skill.description)).toContain("never invoke bsk through bash or shell"); expect(mentionedTools).toEqual([
expect(content).toContain("Always call those injected `browser_*` tools"); "browser_click",
expect(content).toContain("Never use `bash`, `shell`, `exec`"); "browser_console",
expect(content).toContain("## DSH routing reminder"); "browser_emulate",
expect(content.trimEnd()).toMatch( "browser_fill",
/always begin with[\s\S]*`browser_session_start`[\s\S]*`browser_session_stop`\.$/, "browser_get_html",
); "browser_hover",
expect(content).toContain("Owned sessions only"); "browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_network",
"browser_observe",
"browser_press",
"browser_reload",
"browser_request_help",
"browser_screenshot",
"browser_select",
"browser_session_list",
"browser_session_start",
"browser_session_stop",
"browser_snapshot",
"browser_tab_borrow",
"browser_tab_close",
"browser_tab_create",
"browser_tab_list",
"browser_tab_return",
"browser_tab_select",
"browser_wait_for_navigation",
"browser_window_resize",
]);
expect(String(skill.description)).not.toMatch(/\bbsk\b/i);
expect(content).not.toMatch(/\bbsk\b/i);
expect(content).not.toMatch(/```(?:bash|sh|shell)\b/i);
expect(content).not.toMatch(/--[a-z]/);
expect(content).toContain("All browser operations must use the injected tools directly");
expect(content).toContain("Mandatory workflow"); expect(content).toContain("Mandatory workflow");
expect(content).toContain("Refs invalidate after navigation"); expect(content).toContain("Refs invalidate after navigation");
expect(content).toContain("evaluation and interaction recording are intentionally unsupported");
expect(content.length).toBeGreaterThan(10_000); expect(content.length).toBeGreaterThan(10_000);
expect(skill.source).toBe("bundled"); expect(skill.source).toBe("bundled");
// the original YAML frontmatter must not leak into the body // Source frontmatter is registration metadata and must not leak into the body.
expect(content.includes("name: browser-skill\ndescription:")).toBe(false); expect(content.startsWith("---")).toBe(false);
expect(content).not.toContain("name: browser-skill\ndescription:");
// disposer passthrough // disposer passthrough
unregister(); unregister();
expect(skill.__disposed).toBe(true); expect(skill.__disposed).toBe(true);
@@ -142,16 +142,33 @@ describe("tool registration", () => {
const { tools } = setup({}); const { tools } = setup({});
expect([...tools.keys()].sort()).toEqual([ expect([...tools.keys()].sort()).toEqual([
"browser_click", "browser_click",
"browser_console",
"browser_emulate", "browser_emulate",
"browser_fill", "browser_fill",
"browser_get_html",
"browser_hover",
"browser_navigate", "browser_navigate",
"browser_navigate_back",
"browser_navigate_forward",
"browser_network",
"browser_observe", "browser_observe",
"browser_press", "browser_press",
"browser_reload",
"browser_request_help",
"browser_screenshot", "browser_screenshot",
"browser_select",
"browser_session_list", "browser_session_list",
"browser_session_start", "browser_session_start",
"browser_session_stop", "browser_session_stop",
"browser_snapshot", "browser_snapshot",
"browser_tab_borrow",
"browser_tab_close",
"browser_tab_create",
"browser_tab_list",
"browser_tab_return",
"browser_tab_select",
"browser_wait_for_navigation",
"browser_window_resize",
]); ]);
}); });
@@ -436,6 +453,336 @@ describe("interaction tools", () => {
/mutually exclusive/, /mutually exclusive/,
); );
}); });
it("browser_hover and browser_select map their structured arguments", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
hover: { tab_id: 7, used_ref: "@e3", x: 12, y: 24 },
select: {
tab_id: 7,
used_ref: "@e4",
multiple: true,
selected_values: ["a", "b"],
selected_labels: ["Alpha", "Beta"],
},
});
await startSession(tools);
const hovered = (await tools
.get("browser_hover")
?.execute(
{ target: "@e3", modifiers: ["shift"], settleMs: 250, timeoutMs: 2000 },
makeExec(),
)) as { x: number; y: number };
expect(hovered).toMatchObject({ x: 12, y: 24 });
expect(calls[1].args).toEqual([
"hover",
"--session",
"s1",
"--modifiers",
"shift",
"--settle",
"250ms",
"--timeout",
"2000ms",
"@e3",
]);
const selected = (await tools
.get("browser_select")
?.execute({ target: "@e4", values: ["a", "b"], tabId: 7 }, makeExec())) as {
selectedValues: string[];
selectedLabels: string[];
};
expect(selected).toMatchObject({
selectedValues: ["a", "b"],
selectedLabels: ["Alpha", "Beta"],
});
expect(calls[2].args).toEqual([
"select",
"--session",
"s1",
"--tab-id",
"7",
"--value",
"a",
"--value",
"b",
"@e4",
]);
});
});
describe("phase-one tab tools", () => {
it("maps list/create/close/select/borrow/return through the owned session", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"tab list": {
tabs: [
{
tab_id: 7,
title: "Example",
url: "https://example.test",
window_id: 2,
active: true,
scope: "agent",
},
],
},
"tab create": { tab_id: 8, window_id: 2, url: "https://new.test" },
"tab close": { tab_id: 8 },
"tab select": { tab_id: 7, window_id: 2 },
"tab borrow": {
tab_id: 20,
original_window_id: 9,
original_index: 1,
agent_window_id: 2,
},
"tab return": {
tab_id: 20,
returned_to_window_id: 9,
returned_to_index: 1,
fallback: false,
},
});
await startSession(tools);
const listed = (await tools
.get("browser_tab_list")
?.execute({ scope: "agent" }, makeExec())) as { tabs: { tabId: number }[] };
expect(listed.tabs[0].tabId).toBe(7);
const created = (await tools
.get("browser_tab_create")
?.execute({ url: "https://new.test", active: false, index: 1 }, makeExec())) as {
tabId: number;
};
expect(created.tabId).toBe(8);
await tools.get("browser_tab_close")?.execute({ tabId: 8 }, makeExec());
await tools.get("browser_tab_select")?.execute({ tabId: 7 }, makeExec());
const borrowed = (await tools
.get("browser_tab_borrow")
?.execute({ tabId: 20 }, makeExec())) as { originalWindowId: number };
expect(borrowed.originalWindowId).toBe(9);
const returned = (await tools
.get("browser_tab_return")
?.execute({ tabId: 20 }, makeExec())) as { fallback: boolean };
expect(returned.fallback).toBe(false);
expect(calls.slice(1).map((call) => call.args)).toEqual([
["tab", "list", "--session", "s1", "--scope", "agent"],
[
"tab",
"create",
"--session",
"s1",
"--url",
"https://new.test",
"--no-active",
"--index",
"1",
],
["tab", "close", "8", "--session", "s1"],
["tab", "select", "7", "--session", "s1"],
["tab", "borrow", "20", "--session", "s1"],
["tab", "return", "20", "--session", "s1"],
]);
});
});
describe("phase-one navigation tools", () => {
it("maps back/forward/reload/wait and preserves timeout budgets", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"navigate-back": {
tab_id: 7,
previous_url: "https://b.test",
final_url: "https://a.test",
reached: "load",
},
"navigate-forward": {
tab_id: 7,
previous_url: "https://a.test",
final_url: "https://b.test",
reached: "load",
},
reload: {
tab_id: 7,
previous_url: "https://b.test",
final_url: "https://b.test",
reached: "networkidle",
},
"wait-for-navigation": { tab_id: 7, reached: "timeout", error_text: "no navigation" },
});
await startSession(tools);
await tools
.get("browser_navigate_back")
?.execute({ tabId: 7, waitUntil: "commit", timeoutMs: 1000 }, makeExec());
await tools.get("browser_navigate_forward")?.execute({}, makeExec());
await tools
.get("browser_reload")
?.execute({ hard: true, waitUntil: "networkidle" }, makeExec());
const waited = (await tools
.get("browser_wait_for_navigation")
?.execute({ timeoutMs: 2500 }, makeExec())) as { reached: string; errorText: string };
expect(waited).toMatchObject({ reached: "timeout", errorText: "no navigation" });
expect(calls[1].args).toEqual([
"navigate-back",
"--session",
"s1",
"--tab-id",
"7",
"--wait-until",
"commit",
"--timeout",
"1000ms",
]);
expect(calls[2].args).toEqual(["navigate-forward", "--session", "s1"]);
expect(calls[3].args).toEqual([
"reload",
"--session",
"s1",
"--wait-until",
"networkidle",
"--hard",
]);
expect(calls[4].args).toEqual([
"wait-for-navigation",
"--session",
"s1",
"--timeout",
"2500ms",
]);
expect(calls[4].options.timeoutMs).toBe(120_000);
});
});
describe("phase-one support tools", () => {
it("maps request-help targets and structured completion criteria", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"request-help": {
outcome: "completed",
completed_by: "system",
note: "signed in",
tab_id: 7,
resolved_targets: [{ matched: true, ref: "@e3" }],
},
});
await startSession(tools);
const value = (await tools.get("browser_request_help")?.execute(
{
prompt: "Please sign in",
title: "Login required",
targets: ["@e3", "#submit"],
timeoutMs: 300_000,
completionCriteria: {
any: [{ urlContains: "/dashboard" }, { selectorExists: "[data-user]" }],
stableForMs: 1000,
},
},
makeExec(),
)) as { outcome: string; note: string };
expect(value).toMatchObject({ outcome: "completed", note: "signed in" });
expect(calls[1].options.timeoutMs).toBe(315_000);
expect(calls[1].args.slice(0, 8)).toEqual([
"request-help",
"--session",
"s1",
"--prompt",
"Please sign in",
"--timeout",
"300000ms",
"--title",
]);
const criteriaIndex = calls[1].args.indexOf("--completion-criteria");
expect(JSON.parse(calls[1].args[criteriaIndex + 1])).toEqual({
any: [{ url_contains: "/dashboard" }, { selector_exists: "[data-user]" }],
stable_for_ms: 1000,
});
});
it("maps get-html, console, and network results without raw shell access", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"get-html": { tab_id: 7, html: "<main>Hi</main>", truncated: false, byte_size: 15 },
console: {
tab_id: 7,
entries: [
{
sequence: 3,
kind: "exception",
level: "error",
text: "boom",
stack_trace: [{ function_name: "run", url: "https://a.test/app.js", line: 4 }],
},
],
next_since: 3,
truncated: false,
},
network: {
tab_id: 7,
entries: [
{
sequence: 5,
kind: "response",
method: "GET",
url: "https://a.test/api",
status: 200,
mime_type: "application/json",
},
],
next_since: 5,
truncated: false,
},
});
await startSession(tools);
const html = (await tools
.get("browser_get_html")
?.execute({ ref: "@e2", maxBytes: 2048 }, makeExec())) as { html: string };
expect(html.html).toBe("<main>Hi</main>");
const consoleValue = (await tools
.get("browser_console")
?.execute({ since: 1, limit: 10, includeStack: true }, makeExec())) as {
entries: { stackTrace: { functionName: string }[] }[];
};
expect(consoleValue.entries[0].stackTrace[0].functionName).toBe("run");
const networkValue = (await tools
.get("browser_network")
?.execute({ tabId: 7, maxTextChars: 512 }, makeExec())) as {
entries: { mimeType: string }[];
};
expect(networkValue.entries[0].mimeType).toBe("application/json");
expect(calls.slice(1).map((call) => call.args)).toEqual([
["get-html", "--session", "s1", "--ref", "@e2", "--max-bytes", "2048"],
["console", "--session", "s1", "--since", "1", "--limit", "10", "--include-stack"],
["network", "--session", "s1", "--tab-id", "7", "--max-text-chars", "512"],
]);
});
it("resizes only within the CLI-supported range", async () => {
const { tools, calls } = setup({
"session start": START_REPLY("s1"),
"window resize": { window_id: 2, width: 1280, height: 800 },
});
await startSession(tools);
const resize = tools.get("browser_window_resize");
const value = (await resize?.execute({ width: 1280, height: 800 }, makeExec())) as {
windowId: number;
};
expect(value.windowId).toBe(2);
expect(calls[1].args).toEqual([
"window",
"resize",
"--session",
"s1",
"--width",
"1280",
"--height",
"800",
]);
await expect(resize?.execute({ width: 99, height: 800 }, makeExec())).rejects.toThrow(
/100\.\.=7680/,
);
});
}); });
describe("browser_screenshot", () => { describe("browser_screenshot", () => {