src / tools / browserTool.ts
src / tools / browserTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { boundedPreview, errorResult, okResult } from "../core/result";
import { applyGate, compact, consumeApproval, hashOperation } from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import type { BrowserAction, BrowserSnapshot } from "../browser/service";
const actionSchema = z.object({
type: z.enum([
"navigate",
"click",
"click_text",
"type",
"press",
"wait",
"scroll",
"back",
"reload",
"evaluate",
]),
selector: z.string().optional(),
text: z.string().optional(),
value: z.string().optional(),
key: z.string().optional(),
url: z.string().optional(),
milliseconds: z.number().int().min(0).max(30000).optional(),
x: z.number().optional(),
y: z.number().optional(),
script: z.string().optional(),
});
async function snapshotResult(
runtime: PluginRuntime,
operation: string,
snapshot: BrowserSnapshot,
) {
const bounded = boundedPreview(snapshot.text, runtime.settings.maxToolResultChars);
const artifacts = bounded.truncated || snapshot.textTruncated
? [
await runtime.artifacts.writeText(
"web_content",
snapshot.text,
"txt",
`Browser page text from ${snapshot.url}`,
),
]
: undefined;
return okResult({
operation,
summary: `${snapshot.sessionId}: ${snapshot.title || snapshot.url}`,
data: {
...snapshot,
text: bounded.preview,
previewTruncated: bounded.truncated,
omittedChars: bounded.omittedChars,
},
artifacts,
importance: "high",
facts: [
`browser session ${snapshot.sessionId} at ${snapshot.url}`,
snapshot.title ? `page title ${snapshot.title}` : "",
].filter(Boolean),
omit: ["data.text", "data.links"],
});
}
export function createBrowserTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_browser",
description:
"Persistent Chromium sessions for JavaScript-heavy pages. Actions: open, read, control, list, close. Use it only when workspace_research fetch returns an empty or script-rendered page; never for repository work. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["open", "read", "control", "list", "close"]),
url: z.string().optional(),
session_id: z.string().optional(),
actions: z.array(actionSchema).optional(),
},
implementation: async (input: {
action: "open" | "read" | "control" | "list" | "close";
url?: string;
session_id?: string;
actions?: BrowserAction[];
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.browserEnabled) {
throw new AgenticError("PROCESS_DISABLED", "Browser sessions are disabled.");
}
if (input.action === "list") {
const sessions = runtime.browser.list();
return okResult({
operation: "browser.list",
summary: `Listed ${sessions.length} browser session(s).`,
data: sessions,
facts: sessions.map((session) => `browser session ${session.id} at ${session.url}`),
});
}
if (input.action === "open") {
if (!input.url) throw new AgenticError("INVALID_INPUT", "workspace_browser open requires url.");
return await snapshotResult(runtime, "browser.open", await runtime.browser.open(input.url));
}
if (!input.session_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_browser ${input.action} requires session_id.`,
);
}
if (input.action === "close") {
await runtime.browser.close(input.session_id);
return okResult({
operation: "browser.close",
summary: `Closed browser session ${input.session_id}.`,
facts: [`browser session ${input.session_id} closed`],
});
}
if (input.action === "read") {
return await snapshotResult(
runtime,
"browser.read",
await runtime.browser.snapshot(input.session_id),
);
}
if (!input.actions?.length) {
throw new AgenticError("INVALID_INPUT", "workspace_browser control requires actions.");
}
const gate = await applyGate(runtime, ctx, {
kind: "browser",
operation: "browser.control",
operationHash: hashOperation("browser", { sessionId: input.session_id, actions: input.actions }),
title: `Browser control on ${input.session_id} (${input.actions.length} action(s))`,
description: JSON.stringify(input.actions).slice(0, 4000),
destructive: false,
reference: {},
resume: { tool: "workspace_browser", arguments: compact({ ...input }) },
});
if (gate.outcome === "stage") return gate.result;
const snapshot = await runtime.browser.control(input.session_id, input.actions);
await consumeApproval(runtime, ctx, gate, {});
return await snapshotResult(runtime, "browser.control", snapshot);
} catch (error) {
return errorResult(`browser.${input.action}`, error, "high");
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { boundedPreview, errorResult, okResult } from "../core/result";
import { applyGate, compact, consumeApproval, hashOperation } from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import type { BrowserAction, BrowserSnapshot } from "../browser/service";
const actionSchema = z.object({
type: z.enum([
"navigate",
"click",
"click_text",
"type",
"press",
"wait",
"scroll",
"back",
"reload",
"evaluate",
]),
selector: z.string().optional(),
text: z.string().optional(),
value: z.string().optional(),
key: z.string().optional(),
url: z.string().optional(),
milliseconds: z.number().int().min(0).max(30000).optional(),
x: z.number().optional(),
y: z.number().optional(),
script: z.string().optional(),
});
async function snapshotResult(
runtime: PluginRuntime,
operation: string,
snapshot: BrowserSnapshot,
) {
const bounded = boundedPreview(snapshot.text, runtime.settings.maxToolResultChars);
const artifacts = bounded.truncated || snapshot.textTruncated
? [
await runtime.artifacts.writeText(
"web_content",
snapshot.text,
"txt",
`Browser page text from ${snapshot.url}`,
),
]
: undefined;
return okResult({
operation,
summary: `${snapshot.sessionId}: ${snapshot.title || snapshot.url}`,
data: {
...snapshot,
text: bounded.preview,
previewTruncated: bounded.truncated,
omittedChars: bounded.omittedChars,
},
artifacts,
importance: "high",
facts: [
`browser session ${snapshot.sessionId} at ${snapshot.url}`,
snapshot.title ? `page title ${snapshot.title}` : "",
].filter(Boolean),
omit: ["data.text", "data.links"],
});
}
export function createBrowserTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_browser",
description:
"Persistent Chromium sessions for JavaScript-heavy pages. Actions: open, read, control, list, close. Use it only when workspace_research fetch returns an empty or script-rendered page; never for repository work. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["open", "read", "control", "list", "close"]),
url: z.string().optional(),
session_id: z.string().optional(),
actions: z.array(actionSchema).optional(),
},
implementation: async (input: {
action: "open" | "read" | "control" | "list" | "close";
url?: string;
session_id?: string;
actions?: BrowserAction[];
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.browserEnabled) {
throw new AgenticError("PROCESS_DISABLED", "Browser sessions are disabled.");
}
if (input.action === "list") {
const sessions = runtime.browser.list();
return okResult({
operation: "browser.list",
summary: `Listed ${sessions.length} browser session(s).`,
data: sessions,
facts: sessions.map((session) => `browser session ${session.id} at ${session.url}`),
});
}
if (input.action === "open") {
if (!input.url) throw new AgenticError("INVALID_INPUT", "workspace_browser open requires url.");
return await snapshotResult(runtime, "browser.open", await runtime.browser.open(input.url));
}
if (!input.session_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_browser ${input.action} requires session_id.`,
);
}
if (input.action === "close") {
await runtime.browser.close(input.session_id);
return okResult({
operation: "browser.close",
summary: `Closed browser session ${input.session_id}.`,
facts: [`browser session ${input.session_id} closed`],
});
}
if (input.action === "read") {
return await snapshotResult(
runtime,
"browser.read",
await runtime.browser.snapshot(input.session_id),
);
}
if (!input.actions?.length) {
throw new AgenticError("INVALID_INPUT", "workspace_browser control requires actions.");
}
const gate = await applyGate(runtime, ctx, {
kind: "browser",
operation: "browser.control",
operationHash: hashOperation("browser", { sessionId: input.session_id, actions: input.actions }),
title: `Browser control on ${input.session_id} (${input.actions.length} action(s))`,
description: JSON.stringify(input.actions).slice(0, 4000),
destructive: false,
reference: {},
resume: { tool: "workspace_browser", arguments: compact({ ...input }) },
});
if (gate.outcome === "stage") return gate.result;
const snapshot = await runtime.browser.control(input.session_id, input.actions);
await consumeApproval(runtime, ctx, gate, {});
return await snapshotResult(runtime, "browser.control", snapshot);
} catch (error) {
return errorResult(`browser.${input.action}`, error, "high");
}
},
});
}