From d65190174f0adf9d5802b7ebbc16ca7c415bebd7 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 21:36:54 +0800 Subject: [PATCH 1/7] refactor(platform): consolidate crawler tools into unified web tool - Replace separate crawler helpers with unified web tool module - Add browser_operate and fetch_url_via_pdf helpers for web interactions - Move operator service helpers to web/helpers directory - Add new crawler service web router with page content and search endpoints - Update agents to use new web tool structure - Simplify web assistant tool implementation - Remove deprecated action cache functions --- services/crawler/app/main.py | 2 + services/crawler/app/models.py | 24 +++ services/crawler/app/routers/__init__.py | 3 + services/crawler/app/routers/web.py | 90 ++++++++ .../chat/components/thinking-animation.tsx | 16 +- services/platform/convex/_generated/api.d.ts | 34 ++- .../crawler/helpers/fetch_page_content.ts | 168 --------------- .../crawler/helpers/fetch_searxng_results.ts | 109 ---------- .../crawler/helpers/get_search_service_url.ts | 15 -- .../crawler/helpers/search_and_fetch.ts | 203 ------------------ .../agent_tools/crawler/helpers/search_web.ts | 98 --------- .../agent_tools/crawler/helpers/types.ts | 129 ----------- .../agent_tools/crawler/internal_actions.ts | 68 ------ .../agent_tools/crawler/web_read_tool.ts | 170 --------------- .../agent_tools/files/helpers/parse_file.ts | 2 +- .../sub_agents/helpers/operator_types.ts | 22 -- .../sub_agents/web_assistant_tool.ts | 116 +++++----- .../convex/agent_tools/tool_registry.ts | 6 +- .../web/helpers/browser_operate.ts | 92 ++++++++ .../web/helpers/fetch_url_via_pdf.ts | 115 ++++++++++ .../helpers/get_crawler_service_url.ts | 0 .../helpers/get_operator_service_url.ts | 0 .../convex/agent_tools/web/helpers/types.ts | 65 ++++++ .../convex/agent_tools/web/web_tool.ts | 81 +++++++ services/platform/convex/agents/chat/agent.ts | 1 + services/platform/convex/agents/crm/agent.ts | 9 +- .../platform/convex/agents/document/agent.ts | 2 - .../convex/agents/integration/agent.ts | 10 +- services/platform/convex/agents/web/agent.ts | 37 ++-- .../platform/convex/agents/web/mutations.ts | 2 +- .../platform/convex/lib/action_cache/index.ts | 16 -- services/platform/messages/en.json | 3 +- 32 files changed, 585 insertions(+), 1123 deletions(-) create mode 100644 services/crawler/app/routers/web.py delete mode 100644 services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts delete mode 100644 services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts delete mode 100644 services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts delete mode 100644 services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts delete mode 100644 services/platform/convex/agent_tools/crawler/helpers/search_web.ts delete mode 100644 services/platform/convex/agent_tools/crawler/helpers/types.ts delete mode 100644 services/platform/convex/agent_tools/crawler/internal_actions.ts delete mode 100644 services/platform/convex/agent_tools/crawler/web_read_tool.ts delete mode 100644 services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts create mode 100644 services/platform/convex/agent_tools/web/helpers/browser_operate.ts create mode 100644 services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts rename services/platform/convex/agent_tools/{crawler => web}/helpers/get_crawler_service_url.ts (100%) rename services/platform/convex/agent_tools/{sub_agents => web}/helpers/get_operator_service_url.ts (100%) create mode 100644 services/platform/convex/agent_tools/web/helpers/types.ts create mode 100644 services/platform/convex/agent_tools/web/web_tool.ts diff --git a/services/crawler/app/main.py b/services/crawler/app/main.py index 97a3116456..3438b859a9 100644 --- a/services/crawler/app/main.py +++ b/services/crawler/app/main.py @@ -28,6 +28,7 @@ image_router, pdf_router, pptx_router, + web_router, ) from app.services.crawler_service import get_crawler_service from app.services.image_service import get_image_service @@ -93,6 +94,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: app.include_router(image_router) app.include_router(docx_router) app.include_router(pptx_router) +app.include_router(web_router) @app.get("/health", response_model=HealthResponse) diff --git a/services/crawler/app/models.py b/services/crawler/app/models.py index bbd3449a6a..92eae90f63 100644 --- a/services/crawler/app/models.py +++ b/services/crawler/app/models.py @@ -252,3 +252,27 @@ class ParseFileResponse(BaseModel): metadata: dict[str, Any] | None = Field(None, description="Document metadata") vision_used: bool | None = Field(None, description="Whether Vision API was used") error: str | None = Field(None, description="Error message if parsing failed") + + +# ==================== Web Fetch & Extract Models ==================== + + +class WebFetchExtractRequest(BaseModel): + """Request to fetch URL and extract content.""" + + url: HttpUrl = Field(..., description="URL to fetch and extract content from") + instruction: str | None = Field(None, description="Optional AI instruction for content extraction") + timeout: int = Field(60000, description="Navigation timeout in ms (default: 60s)", ge=5000, le=120000) + + +class WebFetchExtractResponse(BaseModel): + """Response from web fetch and extract operation.""" + + success: bool = Field(..., description="Whether the operation was successful") + url: str = Field(..., description="The fetched URL") + title: str | None = Field(None, description="Page title") + content: str = Field(..., description="Extracted text content") + word_count: int = Field(..., description="Number of words in content") + page_count: int = Field(..., description="Number of pages in PDF") + vision_used: bool = Field(False, description="Whether Vision API was used for extraction") + error: str | None = Field(None, description="Error message if operation failed") diff --git a/services/crawler/app/routers/__init__.py b/services/crawler/app/routers/__init__.py index 6edf81eb4a..427a6dcf5a 100644 --- a/services/crawler/app/routers/__init__.py +++ b/services/crawler/app/routers/__init__.py @@ -7,6 +7,7 @@ - image: Image conversion (/api/v1/images) - docx: DOCX document generation and parsing (/api/v1/docx) - pptx: PPTX template generation and parsing (/api/v1/pptx) +- web: Web fetch and extract (/api/v1/web) """ from app.routers.crawler import router as crawler_router @@ -14,6 +15,7 @@ from app.routers.image import router as image_router from app.routers.pdf import router as pdf_router from app.routers.pptx import router as pptx_router +from app.routers.web import router as web_router __all__ = [ "crawler_router", @@ -21,4 +23,5 @@ "image_router", "pdf_router", "pptx_router", + "web_router", ] diff --git a/services/crawler/app/routers/web.py b/services/crawler/app/routers/web.py new file mode 100644 index 0000000000..8c721870ce --- /dev/null +++ b/services/crawler/app/routers/web.py @@ -0,0 +1,90 @@ +""" +Web Router - URL content extraction endpoint. + +Combines URL-to-PDF conversion with Vision-based text extraction. +""" + +from urllib.parse import urlparse + +from fastapi import APIRouter, HTTPException, status +from loguru import logger + +from app.models import WebFetchExtractRequest, WebFetchExtractResponse +from app.services.file_parser_service import get_file_parser_service +from app.services.pdf_service import get_pdf_service + +router = APIRouter(prefix="/api/v1/web", tags=["Web"]) + + +@router.post("/fetch-and-extract", response_model=WebFetchExtractResponse) +async def fetch_and_extract(request: WebFetchExtractRequest): + """ + Fetch a URL, convert to PDF, and extract text content. + + Pipeline: + 1. Navigate to URL with Playwright and render as PDF + 2. Extract text using PyMuPDF + Vision API (handles images, OCR for scanned content) + + Args: + request: URL and optional extraction instruction + + Returns: + Extracted content with metadata + """ + url_str = str(request.url) + hostname = urlparse(url_str).netloc + + try: + pdf_service = get_pdf_service() + if not pdf_service.initialized: + await pdf_service.initialize() + + logger.info(f"Fetching URL as PDF: {url_str}") + pdf_bytes = await pdf_service.url_to_pdf( + url=url_str, + timeout=request.timeout, + ) + + logger.info(f"Extracting content from PDF ({len(pdf_bytes)} bytes)") + parser = get_file_parser_service() + result = await parser.parse_pdf_with_vision( + pdf_bytes, + filename=f"{hostname}.pdf", + user_input=request.instruction, + process_images=True, + ocr_scanned_pages=True, + ) + + if not result.get("success"): + return WebFetchExtractResponse( + success=False, + url=url_str, + content="", + word_count=0, + page_count=0, + error=result.get("error", "Failed to extract content from PDF"), + ) + + full_text = result.get("full_text", "") + word_count = len(full_text.split()) if full_text else 0 + + logger.info( + f"Content extracted: {word_count} words, {result.get('page_count', 0)} pages, " + f"vision_used={result.get('vision_used', False)}" + ) + + return WebFetchExtractResponse( + success=True, + url=url_str, + content=full_text, + word_count=word_count, + page_count=result.get("page_count", 0), + vision_used=result.get("vision_used", False), + ) + + except Exception: + logger.exception(f"Error fetching and extracting URL: {url_str}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch and extract content from URL: {url_str}", + ) from None diff --git a/services/platform/app/features/chat/components/thinking-animation.tsx b/services/platform/app/features/chat/components/thinking-animation.tsx index a0c20f526a..8ca09a3798 100644 --- a/services/platform/app/features/chat/components/thinking-animation.tsx +++ b/services/platform/app/features/chat/components/thinking-animation.tsx @@ -34,21 +34,19 @@ export function ThinkingAnimation({ streamingMessage }: ThinkingAnimationProps) toolName: string, input?: Record, ): ToolDetail => { - if (toolName === 'web_read' && input) { - if (input.operation === 'search' && input.query) { + if (toolName === 'web' && input) { + if (input.operation === 'fetch_url' && input.url) { return { toolName, - displayText: t('thinking.searching', { - query: truncate(String(input.query), 30), + displayText: t('thinking.reading', { + hostname: extractHostname(String(input.url)), }), }; } - if (input.operation === 'fetch_url' && input.url) { + if (input.operation === 'browser_operate' && input.instruction) { return { toolName, - displayText: t('thinking.reading', { - hostname: extractHostname(String(input.url)), - }), + displayText: t('thinking.browsing'), }; } } @@ -66,7 +64,7 @@ export function ThinkingAnimation({ streamingMessage }: ThinkingAnimationProps) customer_read: t('tools.customerRead'), product_read: t('tools.productRead'), rag_search: t('tools.ragSearch'), - web_read: t('tools.webRead'), + web: t('tools.web'), pdf: t('tools.pdf'), image: t('tools.image'), pptx: t('tools.pptx'), diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts index e39ca445dd..06775e35e5 100644 --- a/services/platform/convex/_generated/api.d.ts +++ b/services/platform/convex/_generated/api.d.ts @@ -12,15 +12,6 @@ import type * as accounts_helpers from "../accounts/helpers.js"; import type * as accounts_queries from "../accounts/queries.js"; import type * as accounts_types from "../accounts/types.js"; import type * as accounts_validators from "../accounts/validators.js"; -import type * as agent_tools_crawler_helpers_fetch_page_content from "../agent_tools/crawler/helpers/fetch_page_content.js"; -import type * as agent_tools_crawler_helpers_fetch_searxng_results from "../agent_tools/crawler/helpers/fetch_searxng_results.js"; -import type * as agent_tools_crawler_helpers_get_crawler_service_url from "../agent_tools/crawler/helpers/get_crawler_service_url.js"; -import type * as agent_tools_crawler_helpers_get_search_service_url from "../agent_tools/crawler/helpers/get_search_service_url.js"; -import type * as agent_tools_crawler_helpers_search_and_fetch from "../agent_tools/crawler/helpers/search_and_fetch.js"; -import type * as agent_tools_crawler_helpers_search_web from "../agent_tools/crawler/helpers/search_web.js"; -import type * as agent_tools_crawler_helpers_types from "../agent_tools/crawler/helpers/types.js"; -import type * as agent_tools_crawler_internal_actions from "../agent_tools/crawler/internal_actions.js"; -import type * as agent_tools_crawler_web_read_tool from "../agent_tools/crawler/web_read_tool.js"; import type * as agent_tools_create_json_output_tool from "../agent_tools/create_json_output_tool.js"; import type * as agent_tools_customers_customer_read_tool from "../agent_tools/customers/customer_read_tool.js"; import type * as agent_tools_customers_helpers_count_customers from "../agent_tools/customers/helpers/count_customers.js"; @@ -70,9 +61,7 @@ import type * as agent_tools_sub_agents_document_assistant_tool from "../agent_t import type * as agent_tools_sub_agents_helpers_build_additional_context from "../agent_tools/sub_agents/helpers/build_additional_context.js"; import type * as agent_tools_sub_agents_helpers_check_role_access from "../agent_tools/sub_agents/helpers/check_role_access.js"; import type * as agent_tools_sub_agents_helpers_format_integrations from "../agent_tools/sub_agents/helpers/format_integrations.js"; -import type * as agent_tools_sub_agents_helpers_get_operator_service_url from "../agent_tools/sub_agents/helpers/get_operator_service_url.js"; import type * as agent_tools_sub_agents_helpers_get_or_create_sub_thread from "../agent_tools/sub_agents/helpers/get_or_create_sub_thread.js"; -import type * as agent_tools_sub_agents_helpers_operator_types from "../agent_tools/sub_agents/helpers/operator_types.js"; import type * as agent_tools_sub_agents_helpers_tool_response from "../agent_tools/sub_agents/helpers/tool_response.js"; import type * as agent_tools_sub_agents_helpers_types from "../agent_tools/sub_agents/helpers/types.js"; import type * as agent_tools_sub_agents_helpers_validate_context from "../agent_tools/sub_agents/helpers/validate_context.js"; @@ -82,6 +71,12 @@ import type * as agent_tools_sub_agents_workflow_assistant_tool from "../agent_t import type * as agent_tools_threads_context_search_tool from "../agent_tools/threads/context_search_tool.js"; import type * as agent_tools_tool_registry from "../agent_tools/tool_registry.js"; import type * as agent_tools_types from "../agent_tools/types.js"; +import type * as agent_tools_web_helpers_browser_operate from "../agent_tools/web/helpers/browser_operate.js"; +import type * as agent_tools_web_helpers_fetch_url_via_pdf from "../agent_tools/web/helpers/fetch_url_via_pdf.js"; +import type * as agent_tools_web_helpers_get_crawler_service_url from "../agent_tools/web/helpers/get_crawler_service_url.js"; +import type * as agent_tools_web_helpers_get_operator_service_url from "../agent_tools/web/helpers/get_operator_service_url.js"; +import type * as agent_tools_web_helpers_types from "../agent_tools/web/helpers/types.js"; +import type * as agent_tools_web_web_tool from "../agent_tools/web/web_tool.js"; import type * as agent_tools_workflows_create_workflow_approval from "../agent_tools/workflows/create_workflow_approval.js"; import type * as agent_tools_workflows_create_workflow_tool from "../agent_tools/workflows/create_workflow_tool.js"; import type * as agent_tools_workflows_execute_approved_workflow_creation from "../agent_tools/workflows/execute_approved_workflow_creation.js"; @@ -897,15 +892,6 @@ declare const fullApi: ApiFromModules<{ "accounts/queries": typeof accounts_queries; "accounts/types": typeof accounts_types; "accounts/validators": typeof accounts_validators; - "agent_tools/crawler/helpers/fetch_page_content": typeof agent_tools_crawler_helpers_fetch_page_content; - "agent_tools/crawler/helpers/fetch_searxng_results": typeof agent_tools_crawler_helpers_fetch_searxng_results; - "agent_tools/crawler/helpers/get_crawler_service_url": typeof agent_tools_crawler_helpers_get_crawler_service_url; - "agent_tools/crawler/helpers/get_search_service_url": typeof agent_tools_crawler_helpers_get_search_service_url; - "agent_tools/crawler/helpers/search_and_fetch": typeof agent_tools_crawler_helpers_search_and_fetch; - "agent_tools/crawler/helpers/search_web": typeof agent_tools_crawler_helpers_search_web; - "agent_tools/crawler/helpers/types": typeof agent_tools_crawler_helpers_types; - "agent_tools/crawler/internal_actions": typeof agent_tools_crawler_internal_actions; - "agent_tools/crawler/web_read_tool": typeof agent_tools_crawler_web_read_tool; "agent_tools/create_json_output_tool": typeof agent_tools_create_json_output_tool; "agent_tools/customers/customer_read_tool": typeof agent_tools_customers_customer_read_tool; "agent_tools/customers/helpers/count_customers": typeof agent_tools_customers_helpers_count_customers; @@ -955,9 +941,7 @@ declare const fullApi: ApiFromModules<{ "agent_tools/sub_agents/helpers/build_additional_context": typeof agent_tools_sub_agents_helpers_build_additional_context; "agent_tools/sub_agents/helpers/check_role_access": typeof agent_tools_sub_agents_helpers_check_role_access; "agent_tools/sub_agents/helpers/format_integrations": typeof agent_tools_sub_agents_helpers_format_integrations; - "agent_tools/sub_agents/helpers/get_operator_service_url": typeof agent_tools_sub_agents_helpers_get_operator_service_url; "agent_tools/sub_agents/helpers/get_or_create_sub_thread": typeof agent_tools_sub_agents_helpers_get_or_create_sub_thread; - "agent_tools/sub_agents/helpers/operator_types": typeof agent_tools_sub_agents_helpers_operator_types; "agent_tools/sub_agents/helpers/tool_response": typeof agent_tools_sub_agents_helpers_tool_response; "agent_tools/sub_agents/helpers/types": typeof agent_tools_sub_agents_helpers_types; "agent_tools/sub_agents/helpers/validate_context": typeof agent_tools_sub_agents_helpers_validate_context; @@ -967,6 +951,12 @@ declare const fullApi: ApiFromModules<{ "agent_tools/threads/context_search_tool": typeof agent_tools_threads_context_search_tool; "agent_tools/tool_registry": typeof agent_tools_tool_registry; "agent_tools/types": typeof agent_tools_types; + "agent_tools/web/helpers/browser_operate": typeof agent_tools_web_helpers_browser_operate; + "agent_tools/web/helpers/fetch_url_via_pdf": typeof agent_tools_web_helpers_fetch_url_via_pdf; + "agent_tools/web/helpers/get_crawler_service_url": typeof agent_tools_web_helpers_get_crawler_service_url; + "agent_tools/web/helpers/get_operator_service_url": typeof agent_tools_web_helpers_get_operator_service_url; + "agent_tools/web/helpers/types": typeof agent_tools_web_helpers_types; + "agent_tools/web/web_tool": typeof agent_tools_web_web_tool; "agent_tools/workflows/create_workflow_approval": typeof agent_tools_workflows_create_workflow_approval; "agent_tools/workflows/create_workflow_tool": typeof agent_tools_workflows_create_workflow_tool; "agent_tools/workflows/execute_approved_workflow_creation": typeof agent_tools_workflows_execute_approved_workflow_creation; diff --git a/services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts b/services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts deleted file mode 100644 index c0bb615f72..0000000000 --- a/services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Helper: Fetch Page Content - * - * Fetches and extracts content from a web URL using the crawler service. - */ - -import { getCrawlerServiceUrl } from './get_crawler_service_url'; -import { type FetchUrlsApiResponse, type WebReadFetchUrlResult } from './types'; -import type { ToolCtx } from '@convex-dev/agent'; -import { internal } from '../../../_generated/api'; -import type { Doc } from '../../../_generated/dataModel'; - -import { createDebugLog } from '../../../lib/debug_log'; - -const debugLog = createDebugLog('DEBUG_CRAWLER', '[Crawler]'); - -// Convex imposes a 1 MiB per-value limit. Some pages can be very large and would -// cause tool result messages to exceed this limit when stored by @convex-dev/agent. -// Truncate the extracted page content to keep each message comfortably below -// the limit while still providing rich context for the model. -const MAX_CONTENT_CHARS = 100_000; - -export async function fetchPageContent( - ctx: ToolCtx, - args: { url: string; word_count_threshold?: number }, -): Promise { - const { variables, organizationId } = ctx; - const crawlerServiceUrl = getCrawlerServiceUrl(variables); - - debugLog('tool:web_read:fetch_url start', { - url: args.url, - crawlerServiceUrl, - }); - - // Try cache first when organizationId is available. - if (organizationId) { - try { - // @ts-ignore TS2589: Convex API type instantiation is excessively deep - const cachedPage = (await ctx.runQuery(internal.websites.queries.getWebsitePageByUrlInternal, { organizationId, url: args.url })) as Doc<'websitePages'> | null; - - if (cachedPage && cachedPage.content) { - const rawContent = cachedPage.content ?? ''; - const wasTruncated = rawContent.length > MAX_CONTENT_CHARS; - const content = wasTruncated - ? rawContent.slice(0, MAX_CONTENT_CHARS) - : rawContent; - - debugLog('tool:web_read:fetch_url cache_hit', { - url: args.url, - organizationId, - title: cachedPage.title, - word_count: cachedPage.wordCount, - truncated: wasTruncated, - content_length: rawContent.length, - has_structured_data: !!cachedPage.structuredData, - }); - - return { - operation: 'fetch_url', - success: true, - url: cachedPage.url, - title: cachedPage.title ?? undefined, - content, - word_count: cachedPage.wordCount ?? 0, - metadata: { - ...(cachedPage.metadata ?? {}), - truncated: wasTruncated, - original_content_length: rawContent.length, - }, - structured_data: cachedPage.structuredData ?? undefined, - }; - } - - debugLog('tool:web_read:fetch_url cache_miss', { - url: args.url, - organizationId, - }); - } catch (error) { - debugLog('tool:web_read:fetch_url cache_error', { - url: args.url, - organizationId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - const apiUrl = `${crawlerServiceUrl}/api/v1/urls/fetch`; - - const payload = { - urls: [args.url], - // Low threshold for single URL fetches - user explicitly requested this page - word_count_threshold: args.word_count_threshold ?? 0, - }; - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout - - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Crawler service error: ${response.status} ${errorText}`); - } - - const result = (await response.json()) as FetchUrlsApiResponse; - - if (!result.success) { - throw new Error(`Crawler service returned failure for URL: ${args.url}`); - } - - if (result.pages.length === 0) { - throw new Error( - `No content extracted from URL: ${args.url}. The page may be empty, blocked, or have insufficient text content.`, - ); - } - - const page = result.pages[0]; - - const rawContent = page.content ?? ''; - const wasTruncated = rawContent.length > MAX_CONTENT_CHARS; - const content = wasTruncated - ? rawContent.slice(0, MAX_CONTENT_CHARS) - : rawContent; - - debugLog('tool:web_read:fetch_url success', { - url: args.url, - title: page.title, - word_count: page.word_count, - truncated: wasTruncated, - content_length: rawContent.length, - has_structured_data: !!page.structured_data, - }); - - // Return both structured_data (OpenGraph, JSON-LD) and content. - // structured_data contains machine-readable product info including variant prices. - // content is fallback for pages without structured data. - return { - operation: 'fetch_url', - success: true, - url: page.url, - title: page.title, - content, - word_count: page.word_count, - metadata: { - ...(page.metadata ?? {}), - truncated: wasTruncated, - original_content_length: rawContent.length, - }, - structured_data: page.structured_data, - }; - } catch (error) { - console.error('[tool:web_read:fetch_url] error', { - url: args.url, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} diff --git a/services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts b/services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts deleted file mode 100644 index ac99fb99c3..0000000000 --- a/services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Helper: fetchSearXNGResults - * - * Performs a search against the SearXNG meta search engine. - */ - -import { - type SearchOptions, - type SearchResult, - type SearXNGResponse, -} from './types'; -export type { SearchOptions } from './types'; -import { searchResultsCache } from '../../../lib/action_cache'; -import type { ActionCtx } from '../../../_generated/server'; - -export async function fetchSearXNGResults( - serviceUrl: string, - options: SearchOptions, -): Promise<{ - items: SearchResult[]; - totalResults: number; - suggestions: string[]; -}> { - const url = new URL(`${serviceUrl}/search`); - - // Build query - prepend site: filter if specified - let finalQuery = options.query; - if (options.site) { - finalQuery = `site:${options.site} ${options.query}`; - } - url.searchParams.set('q', finalQuery); - url.searchParams.set('format', 'json'); - url.searchParams.set('pageno', String(options.pageNo ?? 1)); - - if (options.engines && options.engines.length > 0) { - url.searchParams.set('engines', options.engines.join(',')); - } - - if (options.categories && options.categories.length > 0) { - url.searchParams.set('categories', options.categories.join(',')); - } - - if (options.timeRange) { - url.searchParams.set('time_range', options.timeRange); - } - - if (options.safesearch !== undefined) { - url.searchParams.set('safesearch', String(options.safesearch)); - } - - if (options.language) { - url.searchParams.set('language', options.language); - } - - const response = await fetch(url.toString(), { - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`SearXNG Search API error: ${response.status} ${errorText}`); - } - - const data = (await response.json()) as SearXNGResponse; - const numResults = options.numResults ?? 10; - - const items: SearchResult[] = (data.results || []) - .slice(0, numResults) - .map((item) => ({ - title: item.title || '', - link: item.url || '', - snippet: item.content || '', - engines: item.engines, - publishedDate: item.publishedDate, - category: item.category, - })); - - return { - items, - totalResults: data.number_of_results || items.length, - suggestions: data.suggestions || [], - }; -} - -/** - * Cached version of fetchSearXNGResults. - * Use this when calling from actions to benefit from caching. - * Results are cached for 30 minutes. - */ -export async function fetchSearXNGResultsCached( - ctx: ActionCtx, - options: SearchOptions, -): Promise<{ - items: SearchResult[]; - totalResults: number; - suggestions: string[]; -}> { - return await searchResultsCache.fetch(ctx, { - query: options.query, - site: options.site, - pageNo: options.pageNo, - engines: options.engines, - categories: options.categories, - timeRange: options.timeRange, - safesearch: options.safesearch, - language: options.language, - numResults: options.numResults, - }); -} diff --git a/services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts b/services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts deleted file mode 100644 index af7aca76ca..0000000000 --- a/services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Helper: getSearchServiceUrl - * - * Resolve the SearXNG search service base URL from agent variables or environment. - */ - -export function getSearchServiceUrl( - variables?: Record, -): string { - const fromVariables = variables?.SEARCH_SERVICE_URL; - if (typeof fromVariables === 'string' && fromVariables) { - return fromVariables; - } - return process.env.SEARCH_SERVICE_URL || 'http://localhost:8003'; -} diff --git a/services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts b/services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts deleted file mode 100644 index 8197a33c6d..0000000000 --- a/services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Helper: Search and Fetch - * - * Combines web search with parallel content fetching for faster results. - * Searches using SearXNG, then fetches top N results in parallel using batch API. - */ - -import { getSearchServiceUrl } from './get_search_service_url'; -import { getCrawlerServiceUrl } from './get_crawler_service_url'; -import { fetchSearXNGResults } from './fetch_searxng_results'; -import { - type FetchUrlsApiResponse, - type FetchedPageContent, - type WebReadSearchAndFetchResult, -} from './types'; -import type { ToolCtx } from '@convex-dev/agent'; - -import { createDebugLog } from '../../../lib/debug_log'; - -const debugLog = createDebugLog('DEBUG_CRAWLER', '[Crawler]'); - -// Content size limits -const MAX_CONTENT_CHARS_PER_PAGE = 50_000; // Lower limit per page for batch results -const DEFAULT_FETCH_COUNT = 5; - -export interface SearchAndFetchArgs { - query: string; - num_results?: number; - fetch_count?: number; - time_range?: 'day' | 'week' | 'month' | 'year'; - categories?: string[]; - safesearch?: '0' | '1' | '2'; - site?: string; - language?: string; -} - -export async function searchAndFetch( - ctx: ToolCtx, - args: SearchAndFetchArgs, -): Promise { - const { variables } = ctx; - const searchServiceUrl = getSearchServiceUrl(variables); - const crawlerServiceUrl = getCrawlerServiceUrl(variables); - - const numResults = args.num_results ?? 10; - const fetchCount = Math.min(args.fetch_count ?? DEFAULT_FETCH_COUNT, numResults); - - debugLog('tool:web_read:search_and_fetch start', { - query: args.query, - num_results: numResults, - fetch_count: fetchCount, - time_range: args.time_range, - site: args.site, - }); - - const startTime = Date.now(); - - // Step 1: Search - const searchData = await fetchSearXNGResults(searchServiceUrl, { - query: args.query, - numResults, - pageNo: 1, - timeRange: args.time_range, - categories: args.categories, - safesearch: args.safesearch - ? (parseInt(args.safesearch) as 0 | 1 | 2) - : undefined, - site: args.site, - language: args.language, - }); - - const searchDuration = Date.now() - startTime; - debugLog('tool:web_read:search_and_fetch search_complete', { - query: args.query, - results_count: searchData.items.length, - duration_ms: searchDuration, - }); - - if (searchData.items.length === 0) { - return { - operation: 'search_and_fetch', - success: true, - query: args.query, - search_results: [], - total_search_results: 0, - fetched_pages: [], - pages_fetched: 0, - pages_failed: 0, - suggestions: searchData.suggestions, - }; - } - - // Step 2: Fetch top N URLs in parallel using batch API - const urlsToFetch = searchData.items.slice(0, fetchCount).map((item) => item.link); - - debugLog('tool:web_read:search_and_fetch fetching_urls', { - urls: urlsToFetch, - }); - - const fetchStartTime = Date.now(); - const fetchedPages: FetchedPageContent[] = []; - let pagesFailed = 0; - - try { - const apiUrl = `${crawlerServiceUrl}/api/v1/urls/fetch`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 90_000); // 90 second timeout for batch - - const response = await fetch(apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - urls: urlsToFetch, - word_count_threshold: 0, - }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Crawler service error: ${response.status} ${errorText}`); - } - - const result = (await response.json()) as FetchUrlsApiResponse; - - // Process fetched pages - for (const page of result.pages) { - const rawContent = page.content ?? ''; - const wasTruncated = rawContent.length > MAX_CONTENT_CHARS_PER_PAGE; - const content = wasTruncated - ? rawContent.slice(0, MAX_CONTENT_CHARS_PER_PAGE) - : rawContent; - - fetchedPages.push({ - url: page.url, - title: page.title, - content, - word_count: page.word_count, - success: true, - }); - } - - // Track URLs that failed to fetch - const fetchedUrls = new Set(result.pages.map((p) => p.url)); - for (const url of urlsToFetch) { - if (!fetchedUrls.has(url)) { - fetchedPages.push({ - url, - content: '', - word_count: 0, - success: false, - error: 'Failed to fetch content', - }); - pagesFailed++; - } - } - } catch (error) { - // If batch fetch fails completely, mark all as failed - console.error('[tool:web_read:search_and_fetch] batch_fetch_error', { - error: error instanceof Error ? error.message : String(error), - }); - for (const url of urlsToFetch) { - fetchedPages.push({ - url, - content: '', - word_count: 0, - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }); - pagesFailed++; - } - } - - const fetchDuration = Date.now() - fetchStartTime; - const totalDuration = Date.now() - startTime; - - debugLog('tool:web_read:search_and_fetch complete', { - query: args.query, - search_results: searchData.items.length, - pages_fetched: fetchedPages.filter((p) => p.success).length, - pages_failed: pagesFailed, - search_duration_ms: searchDuration, - fetch_duration_ms: fetchDuration, - total_duration_ms: totalDuration, - }); - - // Note: success=true indicates the operation completed (search worked). - // Partial page fetch failures are expected and tracked in pages_failed. - // Callers should check pages_failed and fetched_pages[].success for per-page outcomes. - return { - operation: 'search_and_fetch', - success: true, - query: args.query, - search_results: searchData.items, - total_search_results: searchData.items.length, - fetched_pages: fetchedPages, - pages_fetched: fetchedPages.filter((p) => p.success).length, - pages_failed: pagesFailed, - suggestions: searchData.suggestions, - }; -} diff --git a/services/platform/convex/agent_tools/crawler/helpers/search_web.ts b/services/platform/convex/agent_tools/crawler/helpers/search_web.ts deleted file mode 100644 index 4ac49686cf..0000000000 --- a/services/platform/convex/agent_tools/crawler/helpers/search_web.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Helper: Search Web - * - * Searches the web using SearXNG meta search engine. - * Returns links with titles and snippets for further processing. - */ - -import { getSearchServiceUrl } from './get_search_service_url'; -import { fetchSearXNGResults } from './fetch_searxng_results'; -import { type WebReadSearchResult } from './types'; -import type { ToolCtx } from '@convex-dev/agent'; - -import { createDebugLog } from '../../../lib/debug_log'; - -const debugLog = createDebugLog('DEBUG_CRAWLER', '[Crawler]'); - -// ============================================================================= -// SEARCH WEB OPERATION -// ============================================================================= - -export interface SearchWebArgs { - query: string; - num_results?: number; - page_number?: number; - time_range?: 'day' | 'week' | 'month' | 'year'; - categories?: string[]; - safesearch?: '0' | '1' | '2'; - site?: string; - language?: string; -} - -export async function searchWeb( - ctx: ToolCtx, - args: SearchWebArgs, -): Promise { - const { variables } = ctx; - const searchServiceUrl = getSearchServiceUrl(variables); - const numResults = args.num_results ?? 10; - const pageNumber = args.page_number ?? 1; - - // Standard search mode - debugLog('tool:web_read:search start', { - query: args.query, - num_results: numResults, - page_number: pageNumber, - time_range: args.time_range, - site: args.site, - }); - - try { - const pageData = await fetchSearXNGResults(searchServiceUrl, { - query: args.query, - numResults, - pageNo: pageNumber, - timeRange: args.time_range, - categories: args.categories, - safesearch: args.safesearch - ? (parseInt(args.safesearch) as 0 | 1 | 2) - : undefined, - site: args.site, - language: args.language, - }); - - const hasMore = pageData.items.length >= numResults; - - debugLog('tool:web_read:search success', { - query: args.query, - results_count: pageData.items.length, - has_more: hasMore, - }); - - // Build the next action instruction based on results - const hasResults = pageData.items.length > 0; - const firstUrl = hasResults ? pageData.items[0].link : null; - const nextActionRequired = hasResults - ? `IMPORTANT: These are only search result snippets, NOT the actual page content. To get real data (weather, prices, news, etc.), you MUST now call web_read with { operation: "fetch_url", url: "${firstUrl}" } or another relevant URL from the results above.` - : 'No results found. Try a different search query.'; - - return { - operation: 'search', - success: true, - query: args.query, - results: pageData.items, - total_results: pageData.items.length, - estimated_total: pageData.totalResults, - has_more: hasMore, - next_start_index: hasMore ? pageNumber + 1 : null, - suggestions: pageData.suggestions, - next_action_required: nextActionRequired, - }; - } catch (error) { - console.error('[tool:web_read:search] error', { - query: args.query, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} diff --git a/services/platform/convex/agent_tools/crawler/helpers/types.ts b/services/platform/convex/agent_tools/crawler/helpers/types.ts deleted file mode 100644 index 1349800a14..0000000000 --- a/services/platform/convex/agent_tools/crawler/helpers/types.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Shared types and interfaces for web_read tool and its helpers - */ - -// ============================================================================= -// FETCH URL TYPES -// ============================================================================= - -export interface PageContent { - url: string; - title?: string; - content: string; - word_count: number; - metadata?: Record; - structured_data?: Record; -} - -export interface FetchUrlsApiResponse { - success: boolean; - urls_requested: number; - urls_fetched: number; - pages: PageContent[]; -} - -export type WebReadFetchUrlResult = { - operation: 'fetch_url'; - success: boolean; - url: string; - title?: string; - /** Page content (markdown text extracted from page). */ - content: string; - word_count: number; - metadata?: Record; - /** OpenGraph and JSON-LD structured data. Use this for product pricing/variants when available. */ - structured_data?: Record; -}; - -// ============================================================================= -// WEB SEARCH TYPES -// ============================================================================= - -export interface SearchResult { - title: string; - link: string; - snippet: string; - engines?: string[]; - publishedDate?: string; - category?: string; -} - -export type WebReadSearchResult = { - operation: 'search'; - success: boolean; - query: string; - results: SearchResult[]; - total_results: number; - estimated_total: number; - has_more: boolean; - next_start_index: number | null; - suggestions?: string[]; - /** - * Explicit instruction for the AI on what to do next. - * This helps ensure the AI calls fetch_url after search. - */ - next_action_required: string; -}; - -// ============================================================================= -// SEARCH AND FETCH TYPES (Combined operation) -// ============================================================================= - -export interface FetchedPageContent { - url: string; - title?: string; - content: string; - word_count: number; - success: boolean; - error?: string; -} - -export type WebReadSearchAndFetchResult = { - operation: 'search_and_fetch'; - success: boolean; - query: string; - /** Search results metadata (all results from search) */ - search_results: SearchResult[]; - total_search_results: number; - /** Fetched page contents (top N results, fetched in parallel) */ - fetched_pages: FetchedPageContent[]; - pages_fetched: number; - pages_failed: number; - suggestions?: string[]; -}; - -// ============================================================================= -// SEARXNG API TYPES -// ============================================================================= - -export interface SearXNGResult { - url: string; - title: string; - content?: string; - engine?: string; - engines?: string[]; - publishedDate?: string; - category?: string; -} - -export interface SearXNGResponse { - query: string; - results: SearXNGResult[]; - number_of_results?: number; - suggestions?: string[]; -} - -export interface SearchOptions { - query: string; - numResults?: number; - pageNo?: number; - timeRange?: 'day' | 'week' | 'month' | 'year'; - categories?: string[]; - engines?: string[]; - safesearch?: 0 | 1 | 2; - language?: string; - site?: string; -} - -// (URL helper functions moved to dedicated one-function files -// get_crawler_service_url.ts and get_search_service_url.ts.) diff --git a/services/platform/convex/agent_tools/crawler/internal_actions.ts b/services/platform/convex/agent_tools/crawler/internal_actions.ts deleted file mode 100644 index 8a792bde40..0000000000 --- a/services/platform/convex/agent_tools/crawler/internal_actions.ts +++ /dev/null @@ -1,68 +0,0 @@ -'use node'; - -/** - * Internal actions for crawler operations. - * These wrap helper functions so they can be cached by ActionCache. - */ - -import { internalAction } from '../../_generated/server'; -import { v } from 'convex/values'; -import { - fetchSearXNGResults, - type SearchOptions, -} from './helpers/fetch_searxng_results'; -import { getCrawlerServiceUrl } from './helpers/get_crawler_service_url'; - -/** - * Internal action for fetching SearXNG search results. - * Wrapped for caching - same query should return cached results. - */ -export const fetchSearXNGResultsUncached = internalAction({ - args: { - query: v.string(), - site: v.optional(v.string()), - pageNo: v.optional(v.number()), - engines: v.optional(v.array(v.string())), - categories: v.optional(v.array(v.string())), - timeRange: v.optional( - v.union( - v.literal('day'), - v.literal('week'), - v.literal('month'), - v.literal('year'), - ), - ), - safesearch: v.optional(v.union(v.literal(0), v.literal(1), v.literal(2))), - language: v.optional(v.string()), - numResults: v.optional(v.number()), - }, - returns: v.object({ - items: v.array( - v.object({ - title: v.string(), - link: v.string(), - snippet: v.string(), - engines: v.optional(v.array(v.string())), - publishedDate: v.optional(v.string()), - category: v.optional(v.string()), - }), - ), - totalResults: v.number(), - suggestions: v.array(v.string()), - }), - handler: async (_ctx, args) => { - const serviceUrl = getCrawlerServiceUrl(); - const options: SearchOptions = { - query: args.query, - site: args.site, - pageNo: args.pageNo, - engines: args.engines, - categories: args.categories, - timeRange: args.timeRange, - safesearch: args.safesearch, - language: args.language, - numResults: args.numResults, - }; - return await fetchSearXNGResults(serviceUrl, options); - }, -}); diff --git a/services/platform/convex/agent_tools/crawler/web_read_tool.ts b/services/platform/convex/agent_tools/crawler/web_read_tool.ts deleted file mode 100644 index 69f000be05..0000000000 --- a/services/platform/convex/agent_tools/crawler/web_read_tool.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Convex Tool: Web Read - * - * Unified web operations for agents. - * Supports: - * - operation = 'fetch_url': fetch and extract content from a web URL - * - operation = 'search': search the web using SearXNG meta search engine - */ - -import { z } from 'zod/v4'; -import { createTool, type ToolCtx } from '@convex-dev/agent'; -import type { ToolDefinition } from '../types'; - -import type { - WebReadFetchUrlResult, - WebReadSearchResult, - WebReadSearchAndFetchResult, -} from './helpers/types'; -import { fetchPageContent } from './helpers/fetch_page_content'; -import { searchWeb } from './helpers/search_web'; -import { searchAndFetch } from './helpers/search_and_fetch'; - -// Use a flat object schema instead of discriminatedUnion to ensure OpenAI-compatible JSON Schema -// (discriminatedUnion produces anyOf/oneOf which some providers reject as "type: None") -const webReadArgs = z.object({ - operation: z - .enum(['fetch_url', 'search', 'search_and_fetch']) - .describe( - "Operation to perform: 'fetch_url' (fetch content from URL), 'search' (web search only), or 'search_and_fetch' (search + auto-fetch top results, RECOMMENDED)", - ), - // For fetch_url operation - url: z - .string() - .optional() - .describe( - "Required for 'fetch_url': The URL to fetch content from (must be a valid http/https URL)", - ), - word_count_threshold: z - .number() - .optional() - .describe( - "For 'fetch_url': Minimum word count for content extraction (default: 50). Lower values capture more content.", - ), - // For search operation - query: z - .string() - .optional() - .describe("Required for 'search': The search query text"), - num_results: z - .number() - .optional() - .describe( - "For 'search'/'search_and_fetch': Number of search results to return (default: 10, max: 50).", - ), - fetch_count: z - .number() - .optional() - .describe( - "For 'search_and_fetch': Number of top results to fetch content from (default: 5, max: num_results).", - ), - page_number: z - .number() - .optional() - .describe("For 'search': Page number for pagination (default: 1)."), - time_range: z - .enum(['day', 'week', 'month', 'year']) - .optional() - .describe("For 'search': Filter by time: day, week, month, or year."), - categories: z - .array(z.string()) - .optional() - .describe( - "For 'search': Categories: general, news, science, it, images, videos.", - ), - safesearch: z - .enum(['0', '1', '2']) - .optional() - .describe("For 'search': Safe search: 0=off, 1=moderate, 2=strict."), - site: z - .string() - .optional() - .describe( - 'For \'search\': Limit to domain (e.g., "github.com", "arxiv.org").', - ), - language: z - .string() - .optional() - .describe( - 'For \'search\': Language code (default: "all"). Examples: "en", "de", "fr", "zh".', - ), -}); - -export const webReadTool: ToolDefinition = { - name: 'web_read', - tool: createTool({ - description: `Web content tool for fetching URLs and searching the web. - -OPERATIONS: - -1. search_and_fetch (RECOMMENDED) - Search + auto-fetch top results in ONE call - Searches the web AND fetches content from top results in parallel. - Returns: search results metadata + actual page content from top 5 URLs. - USE THIS for any research task - it's faster than search + manual fetch! - Example: { operation: "search_and_fetch", query: "weather in Zurich today" } - -2. fetch_url - Fetch and extract content from a specific URL - Use when a user provides a URL and wants to know what's on the page. - Returns: page title, main content text, word count, structured_data. - LIMITATIONS: - - CANNOT read PDF, Excel, Word, or other binary files - - For documents in conversation thread, use "context_search" tool - - For knowledge base documents, use "rag_search" tool - -3. search - Search the web (returns snippets only, no content) - Use ONLY when you need many results without fetching content. - Returns URLs with titles and SHORT SNIPPETS ONLY - NOT the actual page content! - -EXAMPLES: -• Research query: { operation: "search_and_fetch", query: "React 19 new features" } -• Fetch specific URL: { operation: "fetch_url", url: "https://example.com/article" } -• Site-specific research: { operation: "search_and_fetch", query: "hooks", site: "react.dev" } -• Browse many results: { operation: "search", query: "React tutorials", num_results: 20 }`, - args: webReadArgs, - handler: async ( - ctx: ToolCtx, - args, - ): Promise => { - if (args.operation === 'fetch_url') { - if (!args.url) { - throw new Error("Missing required 'url' for fetch_url operation"); - } - return fetchPageContent(ctx, { - url: args.url, - word_count_threshold: args.word_count_threshold, - }); - } - - if (args.operation === 'search_and_fetch') { - if (!args.query) { - throw new Error("Missing required 'query' for search_and_fetch operation"); - } - return searchAndFetch(ctx, { - query: args.query, - num_results: args.num_results, - fetch_count: args.fetch_count, - time_range: args.time_range, - categories: args.categories, - safesearch: args.safesearch, - site: args.site, - language: args.language, - }); - } - - // operation === 'search' - if (!args.query) { - throw new Error("Missing required 'query' for search operation"); - } - return searchWeb(ctx, { - query: args.query, - num_results: args.num_results, - page_number: args.page_number, - time_range: args.time_range, - categories: args.categories, - safesearch: args.safesearch, - site: args.site, - language: args.language, - }); - }, - }), -}; diff --git a/services/platform/convex/agent_tools/files/helpers/parse_file.ts b/services/platform/convex/agent_tools/files/helpers/parse_file.ts index 41628e8e7b..2199f2cf52 100644 --- a/services/platform/convex/agent_tools/files/helpers/parse_file.ts +++ b/services/platform/convex/agent_tools/files/helpers/parse_file.ts @@ -5,7 +5,7 @@ */ import { createDebugLog } from '../../../lib/debug_log'; -import { getCrawlerServiceUrl } from '../../crawler/helpers/get_crawler_service_url'; +import { getCrawlerServiceUrl } from '../../web/helpers/get_crawler_service_url'; import type { ActionCtx } from '../../../_generated/server'; import type { Id } from '../../../_generated/dataModel'; diff --git a/services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts b/services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts deleted file mode 100644 index fdd4252181..0000000000 --- a/services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Type definitions for Operator service API responses. - */ - -export interface OperatorTokenUsage { - input_tokens: number; - output_tokens: number; - total_tokens: number; - cache_read_tokens?: number; -} - -export interface OperatorChatResponse { - success: boolean; - message: string; - response: string | null; - error: string | null; - duration_seconds: number | null; - token_usage: OperatorTokenUsage | null; - cost_usd: number | null; - turns: number | null; - sources: string[] | null; -} diff --git a/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts b/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts index 25ab43b384..c6ffc6176d 100644 --- a/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts +++ b/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts @@ -1,98 +1,106 @@ /** * Web Assistant Tool * - * Delegates web tasks to the Operator browser automation service. - * Uses Playwright for browser control with AI-driven navigation. + * Delegates web tasks to the specialized Web Agent. + * This tool is a thin wrapper that creates sub-threads and calls the agent. + * All context management is handled by the agent itself. */ import { z } from 'zod/v4'; import { createTool } from '@convex-dev/agent'; import type { ToolCtx } from '@convex-dev/agent'; import type { ToolDefinition } from '../types'; +import { getOrCreateSubThread } from './helpers/get_or_create_sub_thread'; import { validateToolContext } from './helpers/validate_context'; +import { buildAdditionalContext } from './helpers/build_additional_context'; import { successResponse, - errorResponse, handleToolError, type ToolResponse, } from './helpers/tool_response'; -import { getOperatorServiceUrl } from './helpers/get_operator_service_url'; -import type { OperatorChatResponse } from './helpers/operator_types'; +import { getWebAgentGenerateResponseRef } from '../../lib/function_refs'; + +const WEB_CONTEXT_MAPPING = { + url: 'url', +} as const; export const webAssistantTool = { name: 'web_assistant' as const, tool: createTool({ - description: `Delegate web tasks to the Operator browser automation service. + description: `Delegate web tasks to the specialized Web Agent. Use this tool for: -- Browsing websites and extracting content -- Interacting with web pages (clicking, filling forms) -- Taking screenshots and visual analysis -- Multi-step web automation workflows +- Fetching and extracting content from URLs +- Browsing websites and web pages +- Searching the web for information +- Multi-step web interactions and automation -The Operator uses Playwright for browser control with AI-driven navigation. +The Web Agent has access to: +- fetch_url: Extract content from URLs (URL → PDF → Vision API extraction) +- browser_operate: AI-driven browser automation for searching and interactions EXAMPLES: -- Browse: { userRequest: "Go to example.com and summarize the main content" } -- Interact: { userRequest: "Search for 'AI news' on Google and list top 5 results" } -- Extract: { userRequest: "Find the pricing information on this product page" }`, +- Fetch URL: { userRequest: "Get the content from https://example.com" } +- Search: { userRequest: "Search for the latest news about AI" } +- Extract: { userRequest: "Find the pricing information from this page", url: "https://example.com/pricing" } +- Browse: { userRequest: "Go to GitHub and find trending repositories" }`, args: z.object({ userRequest: z .string() .describe("The user's web-related request in natural language"), + url: z + .string() + .optional() + .describe('Target URL if fetching a specific page'), }), handler: async (ctx: ToolCtx, args): Promise => { const validation = validateToolContext(ctx, 'web_assistant'); if (!validation.valid) return validation.error; - const operatorUrl = getOperatorServiceUrl(ctx.variables); - - console.log('[web_assistant_tool] Calling operator:', { - url: `${operatorUrl}/api/v1/chat`, - message: args.userRequest.slice(0, 100), - }); + const { organizationId, threadId, userId } = validation.context; try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 300_000); - - const response = await fetch(`${operatorUrl}/api/v1/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: args.userRequest }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Operator service error: ${response.status} ${errorText}`, - ); - } + const { threadId: subThreadId, isNew } = await getOrCreateSubThread( + ctx, + { + parentThreadId: threadId, + subAgentType: 'web_assistant', + userId, + }, + ); - const result = (await response.json()) as OperatorChatResponse; + console.log( + '[web_assistant_tool] Sub-thread:', + subThreadId, + isNew ? '(new)' : '(reused)', + ); - if (!result.success) { - return errorResponse(result.error || 'Operator request failed'); - } + const result = await ctx.runAction( + getWebAgentGenerateResponseRef(), + { + threadId: subThreadId, + userId, + organizationId, + promptMessage: args.userRequest, + additionalContext: buildAdditionalContext(args, WEB_CONTEXT_MAPPING), + parentThreadId: threadId, + }, + ); return successResponse( - result.response || '', - result.token_usage - ? { - inputTokens: result.token_usage.input_tokens, - outputTokens: result.token_usage.output_tokens, - totalTokens: result.token_usage.total_tokens, - durationSeconds: result.duration_seconds ?? undefined, - } - : undefined, - 'opencode', - 'operator', - result.sources ?? undefined, + result.text, + { + ...result.usage, + durationSeconds: + result.durationMs !== undefined + ? result.durationMs / 1000 + : undefined, + }, + result.model, + result.provider, + undefined, args.userRequest, ); } catch (error) { diff --git a/services/platform/convex/agent_tools/tool_registry.ts b/services/platform/convex/agent_tools/tool_registry.ts index 75920516fe..ea727fd845 100644 --- a/services/platform/convex/agent_tools/tool_registry.ts +++ b/services/platform/convex/agent_tools/tool_registry.ts @@ -9,7 +9,7 @@ import type { ToolDefinition } from './types'; import { customerReadTool } from './customers/customer_read_tool'; import { productReadTool } from './products/product_read_tool'; import { ragSearchTool } from './rag/rag_search_tool'; -import { webReadTool } from './crawler/web_read_tool'; +import { webTool } from './web/web_tool'; import { workflowReadTool } from './workflows/workflow_read_tool'; import { workflowExamplesTool } from './workflows/workflow_examples_tool'; import { updateWorkflowStepTool } from './workflows/update_workflow_step_tool'; @@ -45,7 +45,7 @@ export const TOOL_NAMES = [ 'customer_read', 'product_read', 'rag_search', - 'web_read', + 'web', 'pdf', 'image', 'pptx', @@ -79,7 +79,7 @@ export const TOOL_REGISTRY = [ customerReadTool, productReadTool, ragSearchTool, - webReadTool, + webTool, workflowReadTool, workflowExamplesTool, updateWorkflowStepTool, diff --git a/services/platform/convex/agent_tools/web/helpers/browser_operate.ts b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts new file mode 100644 index 0000000000..0694c55ec0 --- /dev/null +++ b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts @@ -0,0 +1,92 @@ +/** + * Helper: browserOperate + * + * Delegates browser automation to the Operator service. + * Uses Playwright for browser control with AI-driven navigation. + */ + +import type { ToolCtx } from '@convex-dev/agent'; +import { createDebugLog } from '../../../lib/debug_log'; +import { getOperatorServiceUrl } from './get_operator_service_url'; +import type { WebBrowserOperateResult, OperatorChatResponse } from './types'; + +const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); + +export async function browserOperate( + ctx: ToolCtx, + args: { + instruction: string; + }, +): Promise { + const operatorUrl = getOperatorServiceUrl(ctx.variables); + const apiUrl = `${operatorUrl}/api/v1/chat`; + + debugLog('tool:web:browser_operate start', { + instruction: args.instruction.slice(0, 100), + }); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 300_000); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: args.instruction }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Operator service error: ${response.status} ${errorText}`); + } + + const result = (await response.json()) as OperatorChatResponse; + + if (!result.success) { + debugLog('tool:web:browser_operate failed', { + error: result.error, + }); + return { + operation: 'browser_operate', + success: false, + response: '', + error: result.error || 'Operator request failed', + }; + } + + debugLog('tool:web:browser_operate success', { + hasResponse: !!result.response, + sourcesCount: result.sources?.length ?? 0, + }); + + return { + operation: 'browser_operate', + success: true, + response: result.response || '', + sources: result.sources, + usage: result.token_usage + ? { + inputTokens: result.token_usage.input_tokens, + outputTokens: result.token_usage.output_tokens, + totalTokens: result.token_usage.total_tokens, + durationSeconds: result.duration_seconds, + } + : undefined, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + console.error('[tool:web:browser_operate] error', { + error: errorMessage, + }); + return { + operation: 'browser_operate', + success: false, + response: '', + error: errorMessage, + }; + } +} diff --git a/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts new file mode 100644 index 0000000000..45e0f772ed --- /dev/null +++ b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts @@ -0,0 +1,115 @@ +/** + * Helper: fetchUrlViaPdf + * + * Fetches a URL's content using the PDF extraction pipeline: + * URL -> PDF (Playwright) -> Extract (Vision API) + */ + +import type { ToolCtx } from '@convex-dev/agent'; +import { createDebugLog } from '../../../lib/debug_log'; +import { getCrawlerServiceUrl } from './get_crawler_service_url'; +import type { WebFetchUrlResult, WebFetchExtractApiResponse } from './types'; + +const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); + +const MAX_CONTENT_LENGTH = 100_000; + +export async function fetchUrlViaPdf( + ctx: ToolCtx, + args: { + url: string; + instruction?: string; + }, +): Promise { + const crawlerServiceUrl = getCrawlerServiceUrl(ctx.variables); + const apiUrl = `${crawlerServiceUrl}/api/v1/web/fetch-and-extract`; + + debugLog('tool:web:fetch_url start', { + url: args.url, + hasInstruction: !!args.instruction, + }); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 120_000); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: args.url, + instruction: args.instruction, + timeout: 60000, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Crawler service error: ${response.status} ${errorText}`); + } + + const result = (await response.json()) as WebFetchExtractApiResponse; + + if (!result.success) { + debugLog('tool:web:fetch_url failed', { + url: args.url, + error: result.error, + }); + return { + operation: 'fetch_url', + success: false, + url: args.url, + content: '', + word_count: 0, + page_count: 0, + vision_used: false, + error: result.error || 'Failed to fetch and extract content', + }; + } + + let content = result.content || ''; + const truncated = content.length > MAX_CONTENT_LENGTH; + if (truncated) { + content = content.slice(0, MAX_CONTENT_LENGTH); + } + + debugLog('tool:web:fetch_url success', { + url: args.url, + wordCount: result.word_count, + pageCount: result.page_count, + visionUsed: result.vision_used, + truncated, + }); + + return { + operation: 'fetch_url', + success: true, + url: args.url, + title: result.title, + content, + word_count: result.word_count, + page_count: result.page_count, + vision_used: result.vision_used, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + console.error('[tool:web:fetch_url] error', { + url: args.url, + error: errorMessage, + }); + return { + operation: 'fetch_url', + success: false, + url: args.url, + content: '', + word_count: 0, + page_count: 0, + vision_used: false, + error: errorMessage, + }; + } +} diff --git a/services/platform/convex/agent_tools/crawler/helpers/get_crawler_service_url.ts b/services/platform/convex/agent_tools/web/helpers/get_crawler_service_url.ts similarity index 100% rename from services/platform/convex/agent_tools/crawler/helpers/get_crawler_service_url.ts rename to services/platform/convex/agent_tools/web/helpers/get_crawler_service_url.ts diff --git a/services/platform/convex/agent_tools/sub_agents/helpers/get_operator_service_url.ts b/services/platform/convex/agent_tools/web/helpers/get_operator_service_url.ts similarity index 100% rename from services/platform/convex/agent_tools/sub_agents/helpers/get_operator_service_url.ts rename to services/platform/convex/agent_tools/web/helpers/get_operator_service_url.ts diff --git a/services/platform/convex/agent_tools/web/helpers/types.ts b/services/platform/convex/agent_tools/web/helpers/types.ts new file mode 100644 index 0000000000..cdcfccb249 --- /dev/null +++ b/services/platform/convex/agent_tools/web/helpers/types.ts @@ -0,0 +1,65 @@ +/** + * Shared types and interfaces for web tool and its helpers + */ + +// ============================================================================= +// FETCH URL RESULT (via PDF pipeline) +// ============================================================================= + +export type WebFetchUrlResult = { + operation: 'fetch_url'; + success: boolean; + url: string; + title?: string; + content: string; + word_count: number; + page_count: number; + vision_used: boolean; + error?: string; +}; + +// ============================================================================= +// BROWSER OPERATE RESULT (via Operator service) +// ============================================================================= + +export type WebBrowserOperateResult = { + operation: 'browser_operate'; + success: boolean; + response: string; + error?: string; + sources?: string[]; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + durationSeconds?: number; + }; +}; + +// ============================================================================= +// API RESPONSE TYPES +// ============================================================================= + +export interface WebFetchExtractApiResponse { + success: boolean; + url: string; + title?: string; + content: string; + word_count: number; + page_count: number; + vision_used: boolean; + error?: string; +} + +export interface OperatorChatResponse { + success: boolean; + response?: string; + error?: string; + sources?: string[]; + duration_seconds?: number; + token_usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; +} diff --git a/services/platform/convex/agent_tools/web/web_tool.ts b/services/platform/convex/agent_tools/web/web_tool.ts new file mode 100644 index 0000000000..25c0110ac4 --- /dev/null +++ b/services/platform/convex/agent_tools/web/web_tool.ts @@ -0,0 +1,81 @@ +/** + * Convex Tool: Web + * + * Unified web operations for agents. + * Supports: + * - operation = 'fetch_url': fetch content from URL via PDF extraction pipeline + * - operation = 'browser_operate': browser automation via operator service + */ + +import { z } from 'zod/v4'; +import { createTool, type ToolCtx } from '@convex-dev/agent'; +import type { ToolDefinition } from '../types'; + +import type { WebFetchUrlResult, WebBrowserOperateResult } from './helpers/types'; +import { fetchUrlViaPdf } from './helpers/fetch_url_via_pdf'; +import { browserOperate } from './helpers/browser_operate'; + +const webToolArgs = z.object({ + operation: z + .enum(['fetch_url', 'browser_operate']) + .describe("Operation: 'fetch_url' or 'browser_operate'"), + url: z + .string() + .optional() + .describe("Required for 'fetch_url': The URL to fetch content from"), + instruction: z + .string() + .optional() + .describe( + "For 'fetch_url': Optional AI instruction for extraction. For 'browser_operate': Required browser automation instruction", + ), +}); + +export const webTool: ToolDefinition = { + name: 'web', + tool: createTool({ + description: `Web content tool with two operations: + +1. fetch_url - Fetch and extract content from a URL + Pipeline: URL -> PDF (Playwright) -> Extract (Vision API) + - url: Required, the URL to fetch + - instruction: Optional AI instruction (e.g., "extract pricing info") + Returns: page content, word count, page count, vision_used flag + +2. browser_operate - AI-driven browser automation via Operator service + - instruction: Required, natural language instruction for browser automation + Use for: searching the web, filling forms, multi-step interactions + Example: { operation: "browser_operate", instruction: "Search Google for React 19 features" } + +EXAMPLES: +- Fetch URL: { operation: "fetch_url", url: "https://example.com/article" } +- With instruction: { operation: "fetch_url", url: "https://example.com/pricing", instruction: "extract pricing tiers" } +- Browser search: { operation: "browser_operate", instruction: "Search for latest AI news on Google" } +- Browser interact: { operation: "browser_operate", instruction: "Go to github.com and find the React repository" }`, + args: webToolArgs, + handler: async ( + ctx: ToolCtx, + args, + ): Promise => { + if (args.operation === 'fetch_url') { + if (!args.url) { + throw new Error("Missing required 'url' for fetch_url operation"); + } + return fetchUrlViaPdf(ctx, { + url: args.url, + instruction: args.instruction, + }); + } + + // operation === 'browser_operate' + if (!args.instruction) { + throw new Error( + "Missing required 'instruction' for browser_operate operation", + ); + } + return browserOperate(ctx, { + instruction: args.instruction, + }); + }, + }), +}; diff --git a/services/platform/convex/agents/chat/agent.ts b/services/platform/convex/agents/chat/agent.ts index d4ef30cfb5..dd9c15b46f 100644 --- a/services/platform/convex/agents/chat/agent.ts +++ b/services/platform/convex/agents/chat/agent.ts @@ -150,6 +150,7 @@ export function createChatAgent(options?: { 'integration_assistant', 'workflow_assistant', 'crm_assistant', + 'request_human_input', ]; convexToolNames = options?.convexToolNames ?? defaultToolNames; diff --git a/services/platform/convex/agents/crm/agent.ts b/services/platform/convex/agents/crm/agent.ts index ef182974e6..7ade894060 100644 --- a/services/platform/convex/agents/crm/agent.ts +++ b/services/platform/convex/agents/crm/agent.ts @@ -18,7 +18,6 @@ export const CRM_AGENT_INSTRUCTIONS = `You are a CRM assistant specialized in re **AVAILABLE TOOLS** - customer_read: Read customer data (get_by_id, get_by_email, list operations) - product_read: Read product data (get_by_id, list operations) -- request_human_input: Ask user to select when multiple matches found **ACTION-FIRST PRINCIPLE** Search first, but STOP and ask when multiple matches are found. @@ -31,11 +30,10 @@ ALWAYS search first: **CRITICAL - MULTIPLE MATCHES:** When you find 2 or more matching records and the user's request implies ONE specific target: 1. DO NOT pick one arbitrarily or proceed with all -2. MUST call request_human_input tool with format="single_select" -3. Include email and distinguishing details in each option -4. STOP after calling request_human_input - do NOT continue +2. Return the list of matches with email and distinguishing details +3. Ask the user to clarify which one they mean -Do NOT ask (use plain text or request_human_input): +Do NOT ask: • For IDs when you have a name to search • About scope for simple queries (just return results) • For confirmation on obvious single-match requests @@ -81,7 +79,6 @@ export function createCrmAgent(options?: { maxSteps?: number }) { const convexToolNames: ToolName[] = [ 'customer_read', 'product_read', - 'request_human_input', ]; debugLog('createCrmAgent Loaded tools', { diff --git a/services/platform/convex/agents/document/agent.ts b/services/platform/convex/agents/document/agent.ts index 42f36486a9..b68a364c94 100644 --- a/services/platform/convex/agents/document/agent.ts +++ b/services/platform/convex/agents/document/agent.ts @@ -45,7 +45,6 @@ Do NOT ask about: - txt: Parse/analyze text files OR generate new .txt files from content - image: Analyze images or generate screenshots from HTML/URLs - generate_excel: Create Excel files from structured data -- request_human_input: Ask user for clarification when needed **FILE PARSING (pdf, docx, pptx)** When parsing PDF, DOCX, PPTX files: @@ -130,7 +129,6 @@ export function createDocumentAgent(options?: { maxSteps?: number }) { 'pptx', 'txt', 'generate_excel', - 'request_human_input', ]; debugLog('createDocumentAgent Loaded tools', { diff --git a/services/platform/convex/agents/integration/agent.ts b/services/platform/convex/agents/integration/agent.ts index a09ad9dbe1..eb244f90c9 100644 --- a/services/platform/convex/agents/integration/agent.ts +++ b/services/platform/convex/agents/integration/agent.ts @@ -20,7 +20,6 @@ export const INTEGRATION_AGENT_INSTRUCTIONS = `You are an integration assistant. - integration_batch: Execute multiple parallel read operations - integration_introspect: Discover available integrations and their operations - verify_approval: Verify approval card was created -- request_human_input: Ask user to select when multiple matches found **INTEGRATION NAMES** Only use integrations listed in "## Available Integrations". Never guess names. @@ -37,15 +36,13 @@ ALWAYS proceed directly when you can: **CRITICAL - MULTIPLE MATCHES:** When you find 2 or more matching records and the user's request implies ONE specific target: 1. DO NOT pick one arbitrarily or proceed with all -2. MUST call request_human_input tool with format="single_select" -3. Include distinguishing details in each option (name, email, ID, etc.) -4. STOP after calling request_human_input - do NOT continue +2. Return the list of matches with distinguishing details (name, email, ID, etc.) +3. Ask the user to clarify which one they mean Example - DO THIS: User: "Update guest Yuki Liu's email" → Search for "Yuki Liu" first -→ If multiple found, call request_human_input with options for user to select -→ STOP and wait for user selection +→ If multiple found, return the list and ask user to specify Example - DON'T DO THIS: User: "Update guest Yuki Liu's email" @@ -121,7 +118,6 @@ export function createIntegrationAgent(options?: { maxSteps?: number }) { 'integration_batch', 'integration_introspect', 'verify_approval', - 'request_human_input', ]; debugLog('createIntegrationAgent Loaded tools', { diff --git a/services/platform/convex/agents/web/agent.ts b/services/platform/convex/agents/web/agent.ts index 47fa5975c0..9e18d0919c 100644 --- a/services/platform/convex/agents/web/agent.ts +++ b/services/platform/convex/agents/web/agent.ts @@ -17,38 +17,37 @@ export const WEB_AGENT_INSTRUCTIONS = `You are a web assistant specialized in fe **YOUR ROLE** You handle web-related tasks delegated from the main chat agent: -- Searching the web for information - Fetching content from URLs +- Searching the web and interacting with websites via browser automation - Extracting and summarizing web page content **AVAILABLE TOOLS** -- web_read: Fetch URLs, search the web, or search and fetch in one call -- request_human_input: Ask user for clarification when needed +- web: Web content tool with two operations + - fetch_url: Fetch and extract content from a URL (URL -> PDF -> Vision API extraction) + - browser_operate: AI-driven browser automation for searching and interactions **ACTION-FIRST PRINCIPLE** -Search first, refine if needed. Don't ask for clarification upfront. +Act first, refine if needed. Don't ask for clarification upfront. ALWAYS proceed directly: -• Any search query → just search it, even if broad -• URL provided → fetch it immediately -• Vague topic → search with reasonable interpretation, then offer to refine if results aren't helpful +• URL provided → use web(operation='fetch_url', url='...') +• Search needed → use web(operation='browser_operate', instruction='search for...') +• Website interaction → use web(operation='browser_operate', instruction='...') Do NOT ask: -• For topic clarification before searching +• For topic clarification before acting • About scope or timeframe preferences • For URL confirmation unless it's clearly malformed -**RECOMMENDED WORKFLOW** -For most web research tasks, use the combined search_and_fetch operation: -- web_read(operation='search_and_fetch', query='...'): Searches AND fetches top 5 results in ONE call -- This is faster than search + manual fetch calls -- Returns both search result metadata AND actual page content +**FETCH URL OPERATION** +Use for extracting content from a specific URL: +- web(operation='fetch_url', url='https://...') - Basic extraction +- web(operation='fetch_url', url='https://...', instruction='extract pricing info') - With AI instruction -Example: { operation: "search_and_fetch", query: "weather in Zurich today" } - -**WHEN TO USE OTHER OPERATIONS** -- web_read(operation='fetch_url', url='...'): When user provides a specific URL -- web_read(operation='search', query='...'): When you only need result snippets without content +**BROWSER OPERATE OPERATION** +Use for searching the web or interacting with websites: +- web(operation='browser_operate', instruction='Search Google for React 19 features') +- web(operation='browser_operate', instruction='Go to github.com and find the trending repos') **CONTENT EXTRACTION TIPS** - For long pages, focus on the most relevant sections @@ -65,7 +64,7 @@ Example: { operation: "search_and_fetch", query: "weather in Zurich today" } export function createWebAgent(options?: { maxSteps?: number }) { const maxSteps = options?.maxSteps ?? 5; - const convexToolNames: ToolName[] = ['web_read', 'request_human_input']; + const convexToolNames: ToolName[] = ['web']; debugLog('createWebAgent Loaded tools', { convexCount: convexToolNames.length, diff --git a/services/platform/convex/agents/web/mutations.ts b/services/platform/convex/agents/web/mutations.ts index 4d22f20706..89caba398a 100644 --- a/services/platform/convex/agents/web/mutations.ts +++ b/services/platform/convex/agents/web/mutations.ts @@ -13,7 +13,7 @@ import { WEB_AGENT_INSTRUCTIONS } from './agent'; import type { SerializableAgentConfig } from '../../lib/agent_chat/types'; import type { ToolName } from '../../agent_tools/tool_registry'; -const WEB_AGENT_TOOL_NAMES: ToolName[] = ['web_read', 'request_human_input']; +const WEB_AGENT_TOOL_NAMES: ToolName[] = ['web', 'request_human_input']; const WEB_AGENT_CONFIG: SerializableAgentConfig = { name: 'web-assistant', diff --git a/services/platform/convex/lib/action_cache/index.ts b/services/platform/convex/lib/action_cache/index.ts index 390a162a3e..920a025443 100644 --- a/services/platform/convex/lib/action_cache/index.ts +++ b/services/platform/convex/lib/action_cache/index.ts @@ -53,22 +53,6 @@ export const imageAnalysisCache: ActionCache< ttl: TTL.INDEFINITE, }); -// ============================================ -// Web Crawling Caches -// ============================================ - -/** - * Cache for search engine results. - * Results change frequently, 30-minute TTL. - */ -export const searchResultsCache: ActionCache< - FunctionReference<'action', 'internal'> -> = new ActionCache(components.actionCache, { - action: internal.agent_tools.crawler.internal_actions.fetchSearXNGResultsUncached, - name: `search_results_${CACHE_VERSION}`, - ttl: TTL.THIRTY_MIN, -}); - // ============================================ // Organization Configuration Caches // ============================================ diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index 8e11e41ef5..7712dc25be 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -1508,6 +1508,7 @@ "default": "Thinking", "searching": "Searching \"{query}\"", "reading": "Reading {hostname}", + "browsing": "Browsing the web", "searchingKnowledgeBase": "Searching knowledge base for \"{query}\"", "searchingMultiple": "Searching {queries}", "searchingMore": "Searching {first}, {second} and {count} more", @@ -1519,7 +1520,7 @@ "customerRead": "Reading customer data", "productRead": "Reading product catalog", "ragSearch": "Searching knowledge base", - "webRead": "Fetching web content", + "web": "Fetching web content", "pdf": "Processing PDF", "image": "Analyzing image", "pptx": "Processing presentation", From 96bf8d0ef247fc9052f7606e1485da306e40b402 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 22:07:46 +0800 Subject: [PATCH 2/7] fix(platform): show thinking animation during tool processing and preserve user intent Add isProcessingToolResult state to detect when agent is processing tool results but hasn't resumed streaming text. This fixes the UI gap where no loading indicator was shown between tool completion and agent response. Also update agent and web_assistant_tool prompts to preserve user's specific questions when delegating to sub-agents, instead of reducing them to generic "Get content from URL" requests. --- .../chat/components/chat-interface.tsx | 2 ++ .../chat/components/chat-messages.tsx | 3 ++ .../chat/hooks/use-message-processing.ts | 35 +++++++++++++++++++ .../sub_agents/web_assistant_tool.ts | 12 +++++-- services/platform/convex/agents/chat/agent.ts | 16 +++++++-- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx index ad11c4618b..3d23885508 100644 --- a/services/platform/app/features/chat/components/chat-interface.tsx +++ b/services/platform/app/features/chat/components/chat-interface.tsx @@ -56,6 +56,7 @@ export function ChatInterface({ streamingMessage, pendingToolResponse, hasActiveTools, + isProcessingToolResult, } = useMessageProcessing(threadId); // Merge with pending messages from context for optimistic UI @@ -221,6 +222,7 @@ export function ChatInterface({ streamingMessage={streamingMessage} pendingToolResponse={pendingToolResponse} hasActiveTools={hasActiveTools} + isProcessingToolResult={isProcessingToolResult} aiResponseAreaRef={aiResponseAreaRef} onHumanInputResponseSubmitted={handleHumanInputResponseSubmitted} /> diff --git a/services/platform/app/features/chat/components/chat-messages.tsx b/services/platform/app/features/chat/components/chat-messages.tsx index a46eebe22c..75e87eb1e9 100644 --- a/services/platform/app/features/chat/components/chat-messages.tsx +++ b/services/platform/app/features/chat/components/chat-messages.tsx @@ -23,6 +23,7 @@ interface ChatMessagesProps { streamingMessage: UIMessage | undefined; pendingToolResponse: UIMessage | undefined; hasActiveTools: boolean; + isProcessingToolResult: boolean; aiResponseAreaRef: RefObject; onHumanInputResponseSubmitted?: () => void; } @@ -41,6 +42,7 @@ export function ChatMessages({ streamingMessage, pendingToolResponse, hasActiveTools, + isProcessingToolResult, aiResponseAreaRef, onHumanInputResponseSubmitted, }: ChatMessagesProps) { @@ -173,6 +175,7 @@ export function ChatMessages({ (streamingMessage?.status === 'streaming' && !streamingMessage.text) || hasActiveTools || + isProcessingToolResult || !!pendingToolResponse) && ( )} diff --git a/services/platform/app/features/chat/hooks/use-message-processing.ts b/services/platform/app/features/chat/hooks/use-message-processing.ts index d6759b9d3a..015a2660a3 100644 --- a/services/platform/app/features/chat/hooks/use-message-processing.ts +++ b/services/platform/app/features/chat/hooks/use-message-processing.ts @@ -34,6 +34,7 @@ interface UseMessageProcessingResult { streamingMessage: UIMessage | undefined; pendingToolResponse: UIMessage | undefined; hasActiveTools: boolean; + isProcessingToolResult: boolean; } /** @@ -151,6 +152,39 @@ export function useMessageProcessing( ); }, [streamingMessage?.parts]); + // Check if agent is processing tool result (tool completed but no text after it) + // This handles the gap when sub-agent tools complete but agent hasn't resumed streaming + const isProcessingToolResult = useMemo(() => { + if (!uiMessages?.length) return false; + + const lastAssistant = uiMessages + .filter((m) => m.role === 'assistant') + .at(-1); + if (!lastAssistant?.parts?.length) return false; + if (lastAssistant.status === 'success' || lastAssistant.status === 'failed') + return false; + + const lastToolIndex = lastAssistant.parts.findLastIndex( + (part: { type: string }) => + part.type.startsWith('tool-') && part.type !== 'tool-result', + ); + if (lastToolIndex === -1) return false; + + const lastToolPart = lastAssistant.parts[lastToolIndex] as { + type: string; + state?: string; + }; + if (lastToolPart?.state !== 'output-available') return false; + + const partsAfterTool = lastAssistant.parts.slice(lastToolIndex + 1); + const hasTextAfterTool = partsAfterTool.some( + (part: { type: string; text?: string }) => + part.type === 'text' && part.text, + ); + + return !hasTextAfterTool; + }, [uiMessages]); + return { messages, uiMessages, @@ -160,5 +194,6 @@ export function useMessageProcessing( streamingMessage, pendingToolResponse, hasActiveTools, + isProcessingToolResult, }; } diff --git a/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts b/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts index c6ffc6176d..04ff23968b 100644 --- a/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts +++ b/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts @@ -39,11 +39,17 @@ The Web Agent has access to: - fetch_url: Extract content from URLs (URL → PDF → Vision API extraction) - browser_operate: AI-driven browser automation for searching and interactions +IMPORTANT: Preserve the user's INTENT in userRequest - include what they actually want to know. +Do NOT reduce specific questions to generic "Get the content from URL" requests. + EXAMPLES: -- Fetch URL: { userRequest: "Get the content from https://example.com" } +- Price query: { userRequest: "What is the price of the product at https://example.com/product", url: "https://example.com/product" } - Search: { userRequest: "Search for the latest news about AI" } -- Extract: { userRequest: "Find the pricing information from this page", url: "https://example.com/pricing" } -- Browse: { userRequest: "Go to GitHub and find trending repositories" }`, +- Specific extraction: { userRequest: "Find the opening hours from this page", url: "https://example.com/contact" } +- Browse: { userRequest: "Go to GitHub and find trending repositories" } + +WRONG: { userRequest: "Get the content from https://example.com" } ← Loses the user's specific intent +RIGHT: { userRequest: "What is the shipping policy on https://example.com" } ← Preserves full question`, args: z.object({ userRequest: z diff --git a/services/platform/convex/agents/chat/agent.ts b/services/platform/convex/agents/chat/agent.ts index dd9c15b46f..788ddd29ab 100644 --- a/services/platform/convex/agents/chat/agent.ts +++ b/services/platform/convex/agents/chat/agent.ts @@ -103,7 +103,19 @@ CRITICAL RULES 6) **ACT FIRST** Route to sub-agents immediately. Don't ask users for details that sub-agents can discover. -7) **NO RAW CONTEXT OUTPUT** +7) **PRESERVE USER'S INTENT** + When calling sub-agents, preserve the user's specific question or intent. + Do NOT reduce questions to generic requests like "Get the content from URL". + + Example - WRONG: + User: "https://example.com/product 产品的价格是多少" + → web_assistant({ userRequest: "Get the content from https://example.com/product" }) ← Loses intent! + + Example - CORRECT: + User: "https://example.com/product 产品的价格是多少" + → web_assistant({ userRequest: "这个产品的价格是多少", url: "https://example.com/product" }) + +8) **NO RAW CONTEXT OUTPUT** The system context contains internal formats that are NOT for your output: • NEVER output lines starting with "Tool[" - these are internal tool result logs • NEVER output lines starting with "[Tool Result]" - these are internal records @@ -114,7 +126,7 @@ CRITICAL RULES To use a tool, call it through the function calling mechanism. To report tool results, summarize them in natural language. -8) **PRE-ANALYZED ATTACHMENTS** +9) **PRE-ANALYZED ATTACHMENTS** If the user's CURRENT message contains "[PRE-ANALYZED CONTENT" or sections like: • "**Document: filename.pdf**" followed by content • "**Image: filename.jpg**" followed by description From 2647c9653499276734f2b9ede465faea5d7a4264 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 22:32:17 +0800 Subject: [PATCH 3/7] fix(platform): prioritize current message attachments over previous context Clarify in the pre-analyzed content marker and routing agent instructions that attachments from the current message take priority over any previous conversation context. This prevents the AI from confusing attached documents with previously discussed content. --- services/platform/convex/agents/chat/agent.ts | 3 ++- .../platform/convex/lib/attachments/process_attachments.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/platform/convex/agents/chat/agent.ts b/services/platform/convex/agents/chat/agent.ts index 788ddd29ab..1f83757f9f 100644 --- a/services/platform/convex/agents/chat/agent.ts +++ b/services/platform/convex/agents/chat/agent.ts @@ -131,7 +131,8 @@ CRITICAL RULES • "**Document: filename.pdf**" followed by content • "**Image: filename.jpg**" followed by description • "**Text File: filename.txt**" followed by analysis - These files have ALREADY been analyzed. Answer the user's question directly from this content. + These are attachments from the CURRENT message. They take PRIORITY over any previous context. + Answer the user's question directly from this content. ⚠️ Do NOT call document_assistant for content that is already in the CURRENT message. Note: For follow-up questions about files from PREVIOUS messages, you MAY call document_assistant. diff --git a/services/platform/convex/lib/attachments/process_attachments.ts b/services/platform/convex/lib/attachments/process_attachments.ts index 171fc07857..b2f41ea0ad 100644 --- a/services/platform/convex/lib/attachments/process_attachments.ts +++ b/services/platform/convex/lib/attachments/process_attachments.ts @@ -252,7 +252,7 @@ export async function processAttachments( // Add a marker indicating content has been pre-analyzed in this message contentParts.push({ type: 'text', - text: '\n\n[PRE-ANALYZED CONTENT BELOW - Answer directly from this content without calling document_assistant.]', + text: '\n\n[PRE-ANALYZED CONTENT BELOW - This is the attachment from the CURRENT message. It takes priority over any previous context. Answer directly from this content without calling document_assistant.]', }); // Add parsed document content From 40dce8fac2eb4892028adfd6babffa7e6fbb54df Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 22:41:58 +0800 Subject: [PATCH 4/7] fix(crawler): add SSRF protection for URL fetch endpoint --- services/crawler/app/routers/web.py | 59 ++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/services/crawler/app/routers/web.py b/services/crawler/app/routers/web.py index 8c721870ce..4c2d2d1acf 100644 --- a/services/crawler/app/routers/web.py +++ b/services/crawler/app/routers/web.py @@ -4,6 +4,8 @@ Combines URL-to-PDF conversion with Vision-based text extraction. """ +import socket +from ipaddress import ip_address from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, status @@ -16,6 +18,61 @@ router = APIRouter(prefix="/api/v1/web", tags=["Web"]) +def validate_url_not_private(url_str: str) -> str: + """ + Validate that a URL does not resolve to a private/internal IP address. + + Prevents SSRF attacks by blocking requests to loopback, link-local, + and private RFC1918/IPv6 addresses. + + Args: + url_str: The URL to validate + + Returns: + The hostname if validation passes + + Raises: + HTTPException: If the URL host cannot be resolved or resolves to a private IP + """ + parsed_url = urlparse(url_str) + hostname = parsed_url.hostname or "" + + if not hostname: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid URL: no hostname found", + ) + + try: + resolved_ips = { + ip_address(info[4][0]) + for info in socket.getaddrinfo(hostname, None) + } + except socket.gaierror: + logger.warning(f"SSRF protection: unable to resolve hostname '{hostname}'") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unable to resolve URL host", + ) + + blocked_ips = [ + ip for ip in resolved_ips + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved + ] + + if blocked_ips: + logger.warning( + f"SSRF protection: blocked request to '{hostname}' " + f"(resolved to private/internal IPs: {blocked_ips})" + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL host is not allowed (resolves to private/internal address)", + ) + + return hostname + + @router.post("/fetch-and-extract", response_model=WebFetchExtractResponse) async def fetch_and_extract(request: WebFetchExtractRequest): """ @@ -32,7 +89,7 @@ async def fetch_and_extract(request: WebFetchExtractRequest): Extracted content with metadata """ url_str = str(request.url) - hostname = urlparse(url_str).netloc + hostname = validate_url_not_private(url_str) try: pdf_service = get_pdf_service() From ba38ca2c525cf533e3095d95a4d2d2bf246aed93 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 22:45:23 +0800 Subject: [PATCH 5/7] fix(web): add specific handling for timeout/abort errors in browser_operate --- .../convex/agent_tools/web/helpers/browser_operate.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/platform/convex/agent_tools/web/helpers/browser_operate.ts b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts index 0694c55ec0..160588d57a 100644 --- a/services/platform/convex/agent_tools/web/helpers/browser_operate.ts +++ b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts @@ -77,8 +77,12 @@ export async function browserOperate( : undefined, }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + const isAborted = error instanceof Error && error.name === 'AbortError'; + const errorMessage = isAborted + ? 'Request timed out after 5 minutes' + : error instanceof Error + ? error.message + : 'Unknown error'; console.error('[tool:web:browser_operate] error', { error: errorMessage, }); From 86b3ff93afe45e95cf0dbcbb35adcefc21b74386 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 22:47:07 +0800 Subject: [PATCH 6/7] fix(web): include truncated status in WebFetchUrlResult --- .../platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts | 1 + services/platform/convex/agent_tools/web/helpers/types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts index 45e0f772ed..f7f3527489 100644 --- a/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts +++ b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts @@ -93,6 +93,7 @@ export async function fetchUrlViaPdf( word_count: result.word_count, page_count: result.page_count, vision_used: result.vision_used, + truncated, }; } catch (error) { const errorMessage = diff --git a/services/platform/convex/agent_tools/web/helpers/types.ts b/services/platform/convex/agent_tools/web/helpers/types.ts index cdcfccb249..6968d483e3 100644 --- a/services/platform/convex/agent_tools/web/helpers/types.ts +++ b/services/platform/convex/agent_tools/web/helpers/types.ts @@ -15,6 +15,7 @@ export type WebFetchUrlResult = { word_count: number; page_count: number; vision_used: boolean; + truncated?: boolean; error?: string; }; From 973dfec19e7408ec18cd5ea07ab539b41beab756 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Thu, 5 Feb 2026 22:48:57 +0800 Subject: [PATCH 7/7] fix(agents): reduce PII in multi-match CRM responses --- services/platform/convex/agents/crm/agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/platform/convex/agents/crm/agent.ts b/services/platform/convex/agents/crm/agent.ts index 7ade894060..bf86ccf3ca 100644 --- a/services/platform/convex/agents/crm/agent.ts +++ b/services/platform/convex/agents/crm/agent.ts @@ -30,7 +30,7 @@ ALWAYS search first: **CRITICAL - MULTIPLE MATCHES:** When you find 2 or more matching records and the user's request implies ONE specific target: 1. DO NOT pick one arbitrarily or proceed with all -2. Return the list of matches with email and distinguishing details +2. Return the list of matches with minimal distinguishing details (name, status, source) to help the user identify the correct record 3. Ask the user to clarify which one they mean Do NOT ask: