mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
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:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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 —
|
||||
* the skill catalog entry is the only advertisement. One successful
|
||||
* 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
|
||||
* 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 {
|
||||
let suiteDisposer: (() => void) | undefined;
|
||||
|
||||
@@ -439,14 +439,48 @@ export function actionForLabel(label: string): string {
|
||||
return "observing";
|
||||
case "click":
|
||||
return "clicking";
|
||||
case "hover":
|
||||
return "hovering";
|
||||
case "fill":
|
||||
return "filling";
|
||||
case "select":
|
||||
return "selecting";
|
||||
case "press":
|
||||
return "pressing";
|
||||
case "screenshot":
|
||||
return "capturing";
|
||||
case "emulate":
|
||||
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:
|
||||
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
|
||||
* official skill seam (`ctx.skills`). The catalog entry (name + description)
|
||||
* is resident; the body is loaded only when the model invokes the `skill`
|
||||
* tool. The markdown is build-time assembled into a static module (dsh
|
||||
* prelude + the canonical CLI skill body verbatim), so registration and every
|
||||
* pre-step catalog snapshot are pure in-memory reads — no disk, no process,
|
||||
* no daemon.
|
||||
* tool. The dedicated DeepSeek Harness markdown is embedded into a static
|
||||
* module at build time, so registration and every pre-step catalog snapshot
|
||||
* are pure in-memory reads — no disk, no process, no daemon. The CLI-oriented
|
||||
* repository skill is intentionally a separate interface.
|
||||
*/
|
||||
|
||||
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
|
||||
* 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;
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
import { ownerSessionIds } from "./archive-cleanup";
|
||||
import { trySaveScreenshot } from "./image";
|
||||
import { actionForLabel, type ObservationService } from "./observation";
|
||||
import { registerPhaseOneTools } from "./phase-one-tools";
|
||||
import type { KeyedExecutor } from "./queue";
|
||||
import { type BskRunner, bskInstallMessage, isCommandNotFound, parseBskJson } from "./runner";
|
||||
import type { SessionRegistry } from "./sessions";
|
||||
import { SESSION_PARAM } from "./tool-params";
|
||||
|
||||
/** Plugin configuration resolved from the Schemastery schema in index.ts. */
|
||||
export interface PluginConfig {
|
||||
@@ -62,13 +64,6 @@ const DEVICE_PRESETS = [
|
||||
"galaxy-tab-s8",
|
||||
] 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.
|
||||
* `observeSession` marks the run as model-facing work on that session: it is
|
||||
@@ -82,6 +77,7 @@ async function runBsk(
|
||||
args: string[],
|
||||
label: string,
|
||||
observeSession?: string,
|
||||
runnerTimeoutMs?: number,
|
||||
): Promise<unknown> {
|
||||
// beginAction must fire only when the task actually starts inside the
|
||||
// per-session queue — marking it while still queued would overwrite the
|
||||
@@ -98,7 +94,7 @@ async function runBsk(
|
||||
}
|
||||
return deps.runner.run(args, {
|
||||
signal: exec.signal,
|
||||
timeoutMs: deps.config.defaultTimeoutMs,
|
||||
timeoutMs: runnerTimeoutMs ?? deps.config.defaultTimeoutMs,
|
||||
...(observeSession !== undefined ? { tag: observeSession } : {}),
|
||||
});
|
||||
};
|
||||
@@ -959,6 +955,12 @@ export function registerTools(deps: ToolDeps): () => void {
|
||||
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 () => {
|
||||
for (const dispose of disposers.splice(0)) dispose();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user