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..4c2d2d1acf --- /dev/null +++ b/services/crawler/app/routers/web.py @@ -0,0 +1,147 @@ +""" +Web Router - URL content extraction endpoint. + +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 +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"]) + + +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): + """ + 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 = validate_url_not_private(url_str) + + 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/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/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/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/_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..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 @@ -1,98 +1,112 @@ /** * 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 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 -The Operator uses Playwright for browser control with AI-driven navigation. +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: -- 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" }`, +- 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" } +- 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 .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..160588d57a --- /dev/null +++ b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts @@ -0,0 +1,96 @@ +/** + * 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 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, + }); + 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..f7f3527489 --- /dev/null +++ b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts @@ -0,0 +1,116 @@ +/** + * 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, + truncated, + }; + } 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..6968d483e3 --- /dev/null +++ b/services/platform/convex/agent_tools/web/helpers/types.ts @@ -0,0 +1,66 @@ +/** + * 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; + truncated?: 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..1f83757f9f 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,12 +126,13 @@ 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 • "**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. @@ -150,6 +163,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..bf86ccf3ca 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 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 (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/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 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",