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
14 changes: 13 additions & 1 deletion packages/agent-connector/src/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,14 @@ class ClaudeAdapter extends BaseAdapter {
const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];

cmd.push('--append-system-prompt', systemPrompt);
cmd.push('--disallowedTools', 'AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup');
const disallowedTools = ['AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup'];
if (browserEnabled) {
// Hard-ban the native WebFetch: it can't render JS and bypasses the
// workspace fetch chain. Prompt-level bans proved insufficient.
// WebSearch stays allowed (pure search, no page fetching).
disallowedTools.push('WebFetch');
}
cmd.push('--disallowedTools', ...disallowedTools);

// Resume existing conversation (skipped on retry after stale session)
const sessionId = this._channelSessions[channelName];
Expand Down Expand Up @@ -493,8 +500,13 @@ class ClaudeAdapter extends BaseAdapter {
mcpTools.push(`${pfx}workspace_list_files`, `${pfx}workspace_read_file`);
mcpWriteTools.push(`${pfx}workspace_write_file`, `${pfx}workspace_delete_file`);
}
if (!this.disabledModules.has('search')) {
mcpTools.push(`${pfx}workspace_image_search`);
mcpWriteTools.push(`${pfx}workspace_image_save`);
}
if (!this.disabledModules.has('browser')) {
mcpTools.push(
`${pfx}workspace_fetch_url`,
`${pfx}workspace_browser_list_tabs`,
`${pfx}workspace_browser_snapshot`,
`${pfx}workspace_browser_screenshot`
Expand Down
67 changes: 59 additions & 8 deletions packages/agent-connector/src/adapters/workspace-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,17 @@ function buildBrowserDirective(browserEnabled) {
return (
'\n## Browser Use (MANDATORY)\n' +
'This workspace has the **shared Browser Fabric session** enabled. ' +
'All web browsing MUST go through it so the user can watch the ' +
'session live in their right-side panel and so cookies / state ' +
'persist across agents.\n\n' +
'**Use ONLY these tools for any web browsing:**\n' +
'All web browsing MUST go through the workspace tools so the user can ' +
'watch the session live in their right-side panel and so cookies / ' +
'state persist across agents.\n\n' +
'**To READ a web page, ALWAYS use `mcp__openagents-workspace__workspace_fetch_url` first.** ' +
'It handles JavaScript-heavy pages (Notion, SPAs) automatically and does ' +
'not consume a shared browser tab. Only open a shared browser tab when ' +
'you need to interact with the page (click, type, log in) or when ' +
'workspace_fetch_url reports AUTH_REQUIRED / BOT_CHALLENGE — in that ' +
'case open the URL in a tab and ask a human to complete the login in ' +
'the live view.\n\n' +
'**Tools for interactive browsing:**\n' +
'- `mcp__openagents-workspace__workspace_browser_open`\n' +
'- `mcp__openagents-workspace__workspace_browser_navigate`\n' +
'- `mcp__openagents-workspace__workspace_browser_click`\n' +
Expand All @@ -43,13 +50,19 @@ function buildBrowserDirective(browserEnabled) {
'- `mcp__openagents-workspace__workspace_browser_list_tabs`\n' +
'- `mcp__openagents-workspace__workspace_browser_close`\n' +
'\n' +
'Shared browser tabs are a limited per-workspace resource: close your ' +
'tab (`workspace_browser_close`) as soon as you are done with it. Idle ' +
'tabs are auto-closed after a few minutes.\n\n' +
'If you don\'t have these MCP tools, use `Bash` + `curl` against ' +
'`/v1/browser/tabs` (documented below in Shared Browser).\n\n' +
'`/v1/fetch` and `/v1/browser/tabs` (documented below in Shared Browser).\n\n' +
'**FORBIDDEN — do NOT call any of these:**\n' +
'- `mcp__browsermcp__*` (any local Browser MCP extension tool)\n' +
'- `mcp__playwright__*`, `mcp__puppeteer__*`, `mcp__chrome-devtools__*`, or any other local-browser MCP\n' +
'- `WebFetch`, `WebSearch`, `web_fetch`, `web_search`, or any built-in network/browser tool\n' +
'- `WebFetch` / `web_fetch` — it cannot render JavaScript and fails on ' +
'many pages; use `workspace_fetch_url` instead\n' +
'\n' +
'`WebSearch` / `web_search` (pure search, no page fetching) IS allowed — ' +
'but read the result URLs with `workspace_fetch_url`, not WebFetch.\n\n' +
'If a local browser tool errors with "extension isn\'t connected" or ' +
'"connect your browser", do NOT ask the user to connect anything — ' +
'the local extension is irrelevant here. Immediately switch to the ' +
Expand Down Expand Up @@ -207,6 +220,7 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
// Capabilities preamble
const caps = [];
if (!disabled.has('files')) caps.push('share and read files with other agents and users');
if (!disabled.has('search')) caps.push('search the web for images and post them into the chat');
if (!disabled.has('browser')) caps.push('browse websites in a shared browser');
if (!disabled.has('knowledge')) caps.push('create and access a shared knowledge base');
caps.push('discover other agents in the workspace');
Expand Down Expand Up @@ -287,6 +301,34 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
sections.push(s);
}

// Image search
if (!disabled.has('search')) {
let s = '\n### Image Search\n\n';
s += (
'You CAN find images on the web and show them in the chat.\n\n' +
'**Search images:**\n' +
`${curl} -s -X POST ${baseUrl}/v1/search/images ` +
`-H "${h}" -H "Content-Type: application/json" ` +
`-d '{"query":"golden gate bridge","network":"${workspaceId}","count":10}'\n\n` +
'**To show an image in chat**, embed the result\'s `image_url` in your reply ' +
'as markdown: `![title](image_url)` — it renders inline.\n\n'
);
if (!isPlan) {
s += (
'**To keep a copy in the workspace AND post it as an attachment** ' +
'(survives external links going dead):\n' +
`${curl} -s -X POST ${baseUrl}/v1/files/from_url ` +
`-H "${h}" -H "Content-Type: application/json" ` +
`-d '{"url":"IMAGE_URL","network":"${workspaceId}",` +
`"channel_name":"${channelName}","source":"openagents:${agentName}",` +
`"post_to_channel":true,"caption":"optional message text"}'\n\n` +
'Mention the source page when you share images, and never present a ' +
'search result as license-free.\n'
);
}
sections.push(s);
}

// Browser
if (!disabled.has('browser')) {
let s = '\n### Shared Browser\n\n';
Expand All @@ -304,7 +346,14 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel

if (!isPlan) {
s += (
'**To browse a website**, exec these steps (use exec for each):\n' +
'**To just READ a page (preferred — no tab needed, handles JS pages):**\n' +
`${curl} -s -X POST ${baseUrl}/v1/fetch ` +
`-H "${h}" -H "Content-Type: application/json" ` +
`-d '{"url":"https://example.com","network":"${workspaceId}",` +
`"source":"openagents:${agentName}"}'\n` +
'If it returns error_code AUTH_REQUIRED or BOT_CHALLENGE, open the URL ' +
'in a shared tab (below) and share its `live_url` so a human can log in.\n\n' +
'**To browse interactively** (click/type/login), exec these steps (use exec for each):\n' +
`Step 1 — open tab: ` +
`${curl} -s -X POST ${baseUrl}/v1/browser/tabs ` +
`-H "${h}" -H "Content-Type: application/json" ` +
Expand All @@ -314,7 +363,9 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
`${curl} -s -H "${h}" ${baseUrl}/v1/browser/tabs/TAB_ID/snapshot\n` +
`Step 3 — close tab: ` +
`${curl} -s -X DELETE -H "${h}" ${baseUrl}/v1/browser/tabs/TAB_ID\n` +
'(Replace TAB_ID with the `id` from the step 1 response)\n\n'
'(Replace TAB_ID with the `id` from the step 1 response)\n' +
'Tabs are a limited per-workspace resource — always close yours when done; ' +
'idle tabs are auto-closed after a few minutes.\n\n'
);
}

Expand Down
112 changes: 112 additions & 0 deletions packages/agent-connector/src/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,66 @@ function buildToolDefs(disabledModules) {
);
}

// -- Search module --
if (!disabledModules.has('search')) {
tools.push(
{
name: 'workspace_image_search',
description:
'Search the web for images. Returns image URLs you can show in chat directly by embedding ' +
'markdown — ![title](image_url) — in your reply, or persist with workspace_image_save.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Image search query' },
count: { type: 'number', description: 'Number of results (default 10, max 20)' },
},
required: ['query'],
},
},
{
name: 'workspace_image_save',
description:
'Download an image (or any file) URL into workspace storage. By default it is also posted ' +
'into the current channel as an inline image attachment (set post_to_channel=false to only save).',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Direct image/file URL (e.g. image_url from workspace_image_search)' },
filename: { type: 'string', description: 'Filename to store as (derived from URL if omitted)' },
caption: { type: 'string', description: 'Message text to accompany the posted image' },
post_to_channel: { type: 'boolean', description: 'Post into the chat as an attachment (default true)' },
},
required: ['url'],
},
},
);
}

// -- Browser module --
if (!disabledModules.has('browser')) {
tools.push(
{
name: 'workspace_fetch_url',
description:
'Read a web page as text WITHOUT opening a shared browser tab. Preferred way to read any URL: ' +
'tries a fast static fetch first, and automatically renders JavaScript-heavy pages (Notion, SPAs) ' +
'in a temporary browser session. Only open a shared browser tab (workspace_browser_open) when you ' +
'need to interact with the page (click/type/login) or the result says AUTH_REQUIRED.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'URL to fetch (http/https)' },
mode: {
type: 'string',
enum: ['auto', 'static', 'render'],
description: 'auto (default): static first, browser render if needed. static: never use a browser. render: force browser rendering.',
},
max_chars: { type: 'number', description: 'Max characters of content to return (default 20000)' },
},
required: ['url'],
},
},
{
name: 'workspace_browser_open',
description:
Expand Down Expand Up @@ -629,6 +686,61 @@ class McpServer {

// ── Browser ──

case 'workspace_fetch_url': {
try {
const result = await this.ws.fetchUrl(this.workspaceId, this.token, args.url, {
mode: args.mode || 'auto',
maxChars: args.max_chars,
source: `openagents:${this.agentName}`,
});
const header = [
`URL: ${result.url}`,
result.title ? `Title: ${result.title}` : null,
`Source: ${result.content_source}${result.truncated ? ' (truncated)' : ''}`,
].filter(Boolean).join('\n');
return text(`${header}\n\n${result.content || '(empty page)'}`);
} catch (e) {
const detail = e.data || {};
const parts = [`Fetch failed: ${e.message}`];
if (detail.error_code) parts.push(`Code: ${detail.error_code}`);
if (detail.hint) parts.push(`Hint: ${detail.hint}`);
return text(parts.join('\n'));
}
}

case 'workspace_image_search': {
const data = await this.ws.searchImages(this.workspaceId, this.token, args.query, {
count: args.count || 10,
});
const results = (data && data.results) || [];
if (!results.length) return text(`No image results for "${args.query}".`);
const lines = results.map((r, i) => {
const dims = r.width && r.height ? ` (${r.width}x${r.height})` : '';
return `${i + 1}. ${r.title || 'untitled'}${dims}\n` +
` image_url: ${r.image_url}\n` +
` source: ${r.page_url || r.source || 'unknown'}`;
});
return text(
`Found ${results.length} images for "${args.query}":\n\n${lines.join('\n')}\n\n` +
'To show one in chat, embed it in your reply as markdown: ![title](image_url). ' +
'To keep a copy in the workspace (and post it as an attachment), call workspace_image_save with the image_url.'
);
}

case 'workspace_image_save': {
const result = await this.ws.uploadFileFromUrl(this.workspaceId, this.token, args.url, {
filename: args.filename,
channelName: this.channelName,
source: `openagents:${this.agentName}`,
postToChannel: args.post_to_channel !== false,
caption: args.caption,
});
const posted = result.posted_to_channel
? ' and posted to the channel'
: '';
return text(`Saved ${result.filename} (${result.content_type}, ${result.size} bytes, id: ${result.id})${posted}.`);
}

case 'workspace_browser_open': {
const opts = {
url: args.url || 'about:blank',
Expand Down
44 changes: 43 additions & 1 deletion packages/agent-connector/src/workspace-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,31 @@ class WorkspaceClient {
return data.data || data;
}

/**
* Search the web for images via POST /v1/search/images.
*/
async searchImages(workspaceId, token, query, { count = 10 } = {}) {
const body = { query, network: workspaceId, count };
const data = await this._post('/v1/search/images', body, this._wsHeaders(token), 30000);
return data.data || data;
}

/**
* Download a URL into workspace storage via POST /v1/files/from_url.
* With postToChannel, the file is also posted into the chat as an
* inline attachment.
*/
async uploadFileFromUrl(workspaceId, token, url, {
filename, channelName, source = 'human:user', postToChannel = false, caption,
} = {}) {
const body = { url, network: workspaceId, source, post_to_channel: postToChannel };
if (filename) body.filename = filename;
if (channelName) body.channel_name = channelName;
if (caption) body.caption = caption;
const data = await this._post('/v1/files/from_url', body, this._wsHeaders(token), 90000);
return data.data || data;
}

/**
* List files via GET /v1/files.
*/
Expand Down Expand Up @@ -583,6 +608,19 @@ class WorkspaceClient {
return data.data || data;
}

/**
* Server-side fetch chain via POST /v1/fetch — reads a page WITHOUT
* holding a shared browser tab. Static HTTP first; JS-heavy pages are
* rendered in an ephemeral browser session that is closed immediately.
*/
async fetchUrl(workspaceId, token, url, { mode = 'auto', maxChars, source } = {}) {
const body = { url, network: workspaceId, mode };
if (maxChars) body.max_chars = maxChars;
if (source) body.source = source;
const data = await this._post('/v1/fetch', body, this._wsHeaders(token), 90000);
return data.data || data;
}

/**
* List persistent browser contexts via GET /v1/browser/contexts.
*/
Expand Down Expand Up @@ -878,7 +916,11 @@ class WorkspaceClient {
if (typeof msg === 'string' && msg.toLowerCase().includes('session_revoked')) {
reject(new SessionRevokedError(msg));
} else {
reject(new Error(msg));
const err = new Error(msg);
// Preserve structured error details (error_code, hint,
// quota occupancy, ...) so tool handlers can surface them.
if (parsed.data && typeof parsed.data === 'object') err.data = parsed.data;
reject(err);
}
} else {
resolve(parsed);
Expand Down
Loading
Loading