Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/@types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ export interface ActiveSession {
// The creation session id returned by POST /profile. Reconnects attach to it
// via /chromium/agent?sessionId rather than launching a new browser.
creationSessionId?: string;
// Origin tag forwarded to the browser WS as `x-browserless-mcp-source` so the
// server can attribute captured skills (mcp_client, cli_agent, …).
readonly source?: string;
readonly compliant: boolean;
reconnecting?: Promise<WebSocket>;
skillState: SkillFireState;
lastUsedAt: number;
Expand Down
20 changes: 18 additions & 2 deletions src/lib/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ const connect = (
profile?: string,
sessionId?: string,
compliant = false,
source?: string,
): Promise<WebSocket> =>
new Promise((resolve, reject) => {
const wsUrl = buildAgentWsUrl(
Expand All @@ -496,7 +497,12 @@ const connect = (
sessionId,
compliant,
);
const ws = new WebSocket(wsUrl);
// Forward the origin on the upgrade so the server can attribute captured
// skills; reuses the same header the MCP already receives on its inbound.
const ws = new WebSocket(
wsUrl,
source ? { headers: { 'x-browserless-mcp-source': source } } : undefined,
);
let settled = false;

const settle = (err: Error | null, value?: WebSocket): void => {
Expand Down Expand Up @@ -621,6 +627,7 @@ export const getOrCreateSession = async (
createProfile?: CreateProfileParams,
attachSessionId?: string,
compliant = false,
source?: string,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
): Promise<ActiveSession> => {
sweepSessions();
const key = getSessionKey(
Expand All @@ -633,7 +640,11 @@ export const getOrCreateSession = async (
);
const existing = sessions.get(key);

if (existing && existing.ws.readyState === WebSocket.OPEN) {
if (
existing &&
existing.ws.readyState === WebSocket.OPEN &&
existing.source === source
) {
existing.lastUsedAt = Date.now();
return existing;
}
Expand Down Expand Up @@ -673,6 +684,7 @@ export const getOrCreateSession = async (
profile,
creationSessionId,
compliant,
source,
);
const session: ActiveSession = {
ws,
Expand All @@ -683,6 +695,8 @@ export const getOrCreateSession = async (
profile,
createProfile,
creationSessionId,
source,
compliant,
skillState: createSkillState(),
lastUsedAt: Date.now(),
};
Expand Down Expand Up @@ -732,6 +746,8 @@ export const send = async (
session.proxy,
session.profile,
session.creationSessionId,
session.compliant,
session.source,
).finally(() => {
session.reconnecting = undefined;
});
Expand Down
1 change: 1 addition & 0 deletions src/skills/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const AGENT_SYSTEM_PROMPT = `READ CAREFULLY: Execute browser commands in
## Site recipes (site-specific, NOT auto-injected) — CHECK FIRST
Many specific sites (marketplaces, gov portals, travel, real-estate, etc.) have a **tuned recipe** for a given task — proven selectors, API shortcuts, proxy needs, and known gotchas that a from-scratch plan will miss. These are **not** auto-injected; you must ask for them, and a recipe **overrides** any plan you'd build yourself (including "just use a prefiltered URL + evaluate").
**This is step 0 of every task — do it before your first \`goto\`.** The moment you know the target host (the user named the site, or you resolved which site to use), call \`browserless_skill { site: "<host>" }\` — e.g. \`{ site: "airbnb.com" }\`. If it lists a recipe matching your task, load it with \`browserless_skill { id: "<host>/<slug>" }\` and follow it. Only when there's no match do you plan the steps yourself. Skipping this check on a supported site is a mistake — it's one cheap call.
**Report the outcome (only if you loaded a site recipe).** As your final command in the run, send \`{ method: "reportSkillOutcome", params: { domain: "<host>", task: "<slug>", success: <bool> } }\` inside \`commands\` — where \`domain\`/\`task\` are the loaded recipe's \`<host>\`/\`<slug>\` and \`success\` is whether the recipe actually got you the result. This refines shared recipes and retires ones that stop working. Send it once, and only when you loaded a recipe — never for a self-planned run. Send it as your last command **before** any \`close\` (close ends the run and anything after it is dropped).

## Proxy (optional)
Proxy config is a **top-level tool argument** (\`proxy\`, \`proxyCountry\`, etc. on the tool call itself) — it is applied when the session is opened. **NEVER call \`proxy\` as a method inside \`commands\`** — a \`{ method: "proxy", ... }\` JSON-RPC mutation does NOT change the upstream proxy on an already-open session and will silently no-op.
Expand Down
27 changes: 27 additions & 0 deletions src/tools/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ export function registerAgentTools(
createProfile,
attachSessionId,
compliant,
mcpSource.source,
);
} catch (connErr: unknown) {
sendAnalytics(false);
Expand All @@ -652,6 +653,7 @@ export function registerAgentTools(
createProfile,
attachSessionId,
compliant,
mcpSource.source,
);
} catch (connErr: unknown) {
// No retry when the server gave a definitive 4xx — re-attempting
Expand All @@ -678,6 +680,7 @@ export function registerAgentTools(
// else the first URL seen this batch — so [goto A, goto B, snapshot]
// still detects the A→snapshot cross-origin transition.
let crossOriginBaseline: string | undefined = agentSession.lastUrl;
let promptSent = false;
for (const cmd of commands) {
if (cmd.method === 'close') {
closeSession(
Expand All @@ -692,6 +695,14 @@ export function registerAgentTools(
closedDuringBatch = true;
break;
}
if (cmd.method === 'reportSkillOutcome') {
try {
await send(agentSession, cmd.method, cmd.params);
} catch {
// noop
}
continue;
}
Comment thread
andyMrtnzP marked this conversation as resolved.

log.info(`agent: ${cmd.method} ${JSON.stringify(cmd.params)}`);

Expand All @@ -706,6 +717,12 @@ export function registerAgentTools(
outboundParams = { ...cmd.params };
delete (outboundParams as Record<string, unknown>).toDisk;
}
// Cascade the self-reported prompt once per session so the server can
// author a first-party skill from the run (server keeps the first).
if (prompt && !promptSent) {
outboundParams = { ...outboundParams, _prompt: prompt };
promptSent = true;
}

let resp;
try {
Expand Down Expand Up @@ -813,6 +830,16 @@ export function registerAgentTools(
// If the batch ended with close, format the result around the
// command before close (close itself has no useful payload).
const reportable = closedDuringBatch ? results.slice(0, -1) : results;
// Nothing user-facing ran (batch was only close and/or an internal
// reportSkillOutcome) — the deref below would throw, so short-circuit.
if (reportable.length === 0) {
return [
{
type: 'text' as const,
text: closedDuringBatch ? 'Browser session closed.' : 'Done.',
},
];
}
const last = reportable[reportable.length - 1];
const lastResult = last.result as Record<string, unknown>;
const lastCmd = commands[reportable.length - 1];
Expand Down