diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index a17a3451c..1c98d14c4 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -88,7 +88,11 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): assert isinstance(item, ActionImproveData) question = item.data if len(question) < 12 and len(options.attaches) == 0: - confirm = await question_confirm(question_agent, question) + messages, confirm = await question_confirm( + question_agent, question, timeout=8.0, task_id=options.task_id + ) + for msg in messages: + yield msg else: confirm = True @@ -109,7 +113,21 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): camel_task.additional_info = {Path(file_path).name: file_path for file_path in options.attaches} sub_tasks = await asyncio.to_thread(workforce.eigent_make_sub_tasks, camel_task) - summary_task_content = await summary_task(summary_task_agent, camel_task) + try: + summary_task_content = await asyncio.wait_for( + summary_task(summary_task_agent, camel_task), timeout=10 + ) + except asyncio.TimeoutError: + logger.warning(f"summary_task timeout for task {options.task_id}") + # Fallback to a minimal summary to unblock UI + fallback_name = "Task" + content_preview = camel_task.content if hasattr(camel_task, "content") else "" + if content_preview is None: + content_preview = "" + fallback_summary = ( + (content_preview[:80] + "...") if len(content_preview) > 80 else content_preview + ) + summary_task_content = f"{fallback_name}|{fallback_summary}" yield to_sub_tasks(camel_task, summary_task_content) # tracer.stop() # tracer.save("trace.json") @@ -287,8 +305,27 @@ def add_sub_tasks(camel_task: Task, update_tasks: list[TaskContent]): ) -async def question_confirm(agent: ListenChatAgent, prompt: str) -> str | Literal[True]: - prompt = f""" +async def question_confirm( + agent: ListenChatAgent, + prompt: str, + timeout: float = 8.0, + task_id: str = "" +) -> tuple[list, str | Literal[True]]: + """ + Confirm whether a question requires workforce processing. + + Args: + agent: The agent to use for classification + prompt: The user's question + timeout: Timeout in seconds (default: 8.0) + task_id: Task ID for logging + + Returns: + Tuple of (messages_to_yield, confirm_result) + - messages_to_yield: List of SSE messages to yield (empty if no timeout) + - confirm_result: True to proceed with workforce, or sse_json for user confirmation + """ + analysis_prompt = f""" > **Your Role:** You are a highly capable agent. Your primary function is to analyze a user's request and determine the appropriate course of action. > > **Your Process:** @@ -303,12 +340,26 @@ async def question_confirm(agent: ListenChatAgent, prompt: str) -> str | Literal > * **For a Simple Query:** Provide a direct and helpful response. > * **For a Complex Task:** Your *only* response should be "yes". This will trigger a specialized workforce to handle the task. Do not include any other text, punctuation, or pleasantries. """ - resp = agent.step(prompt) - logger.info(f"resp: {agent.chat_history}") - if resp.msgs[0].content.lower() != "yes": - return sse_json("wait_confirm", {"content": resp.msgs[0].content}) - else: - return True + + try: + resp = await asyncio.wait_for( + asyncio.to_thread(agent.step, analysis_prompt), + timeout=timeout + ) + logger.info(f"resp: {agent.chat_history}") + + if resp.msgs[0].content.lower() != "yes": + return ([], sse_json("wait_confirm", {"content": resp.msgs[0].content})) + else: + return ([], True) + + except asyncio.TimeoutError: + logger.warning(f"question_confirm timeout for task {task_id}") + notice = sse_json( + "notice", + {"notice": "Quick classification timed out. Responding directly."} + ) + return ([notice], True) async def summary_task(agent: ListenChatAgent, task: Task) -> str: diff --git a/backend/app/utils/agent.py b/backend/app/utils/agent.py index 8d89e8285..cef8d6122 100644 --- a/backend/app/utils/agent.py +++ b/backend/app/utils/agent.py @@ -449,7 +449,7 @@ class ListenChatAgent(ChatAgent): # Clone tools and collect toolkits that need registration cloned_tools, toolkits_to_register = self._clone_tools() - + new_agent = ListenChatAgent( api_task_id=self.api_task_id, agent_name=self.agent_name, @@ -465,7 +465,6 @@ class ListenChatAgent(ChatAgent): response_terminators=self.response_terminators, scheduling_strategy=self.model_backend.scheduling_strategy.__name__, max_iteration=self.max_iteration, - agent_id=self.agent_id, stop_event=self.stop_event, tool_execution_timeout=self.tool_execution_timeout, mask_tool_output=self.mask_tool_output, @@ -751,6 +750,8 @@ def search_agent(options: Chat): ], ) + # Save reference before registering for toolkits_to_register_agent + web_toolkit_for_agent_registration = web_toolkit_custom web_toolkit_custom = message_integration.register_toolkits(web_toolkit_custom) terminal_toolkit = TerminalToolkit(options.task_id, Agents.search_agent, safe_mode=True, clone_current_env=False) terminal_toolkit = message_integration.register_functions([terminal_toolkit.shell_exec]) @@ -815,7 +816,7 @@ The current date is {datetime.date.today()}. For any date-related tasks, you MUS - **CRITICAL URL POLICY**: You are STRICTLY FORBIDDEN from inventing, guessing, or constructing URLs yourself. You MUST only use URLs from trusted sources: - 1. URLs returned by search tools (like `search_google` or `search_exa`) + 1. URLs returned by search tools (`search_google`) 2. URLs found on webpages you have visited through browser tools 3. URLs provided by the user in their request Fabricating or guessing URLs is considered a critical error and must @@ -861,8 +862,6 @@ Your approach depends on available search tools: sites using `browser_type` and submit with `browser_enter` - **Extract URLs from results**: Only use URLs that appear in the search results on these websites -- **Alternative Search**: If available, use `search_exa` for additional - results **Common Browser Operations (both scenarios):** - **Navigation and Exploration**: Use `browser_visit_page` to open URLs. @@ -899,6 +898,7 @@ Your approach depends on available search tools: NoteTakingToolkit.toolkit_name(), TerminalToolkit.toolkit_name(), ], + toolkits_to_register_agent=[web_toolkit_for_agent_registration], ) @@ -930,10 +930,10 @@ async def document_agent(options: Chat): *terminal_toolkit.get_tools(), *await GoogleDriveMCPToolkit.get_can_use_tools(options.task_id, options.get_bun_env()), ] - if env("EXA_API_KEY") or options.is_cloud(): - search_toolkit = SearchToolkit(options.task_id, Agents.document_agent).search_exa - search_toolkit = message_integration.register_functions([search_toolkit]) - tools.extend(search_toolkit) + # if env("EXA_API_KEY") or options.is_cloud(): + # search_toolkit = SearchToolkit(options.task_id, Agents.document_agent).search_exa + # search_toolkit = message_integration.register_functions([search_toolkit]) + # tools.extend(search_toolkit) system_message = f""" You are a Documentation Specialist, responsible for creating, modifying, and @@ -1162,10 +1162,10 @@ def multi_modal_agent(options: Chat): audio_analysis_toolkit = message_integration.register_toolkits(audio_analysis_toolkit) tools.extend(audio_analysis_toolkit.get_tools()) - if env("EXA_API_KEY") or options.is_cloud(): - search_toolkit = SearchToolkit(options.task_id, Agents.multi_modal_agent).search_exa - search_toolkit = message_integration.register_functions([search_toolkit]) - tools.extend(search_toolkit) + # if env("EXA_API_KEY") or options.is_cloud(): + # search_toolkit = SearchToolkit(options.task_id, Agents.multi_modal_agent).search_exa + # search_toolkit = message_integration.register_functions([search_toolkit]) + # tools.extend(search_toolkit) system_message = f""" @@ -1294,8 +1294,8 @@ async def social_medium_agent(options: Chat): # *DiscordToolkit(options.task_id).get_tools(), # Not supported temporarily # *GoogleSuiteToolkit(options.task_id).get_tools(), # Not supported temporarily ] - if env("EXA_API_KEY") or options.is_cloud(): - tools.append(FunctionTool(SearchToolkit(options.task_id, Agents.social_medium_agent).search_exa)) + # if env("EXA_API_KEY") or options.is_cloud(): + # tools.append(FunctionTool(SearchToolkit(options.task_id, Agents.social_medium_agent).search_exa)) return agent_model( Agents.social_medium_agent, BaseMessage.make_assistant_message( diff --git a/backend/app/utils/listen/toolkit_listen.py b/backend/app/utils/listen/toolkit_listen.py index 63de14117..1d7fd85a6 100644 --- a/backend/app/utils/listen/toolkit_listen.py +++ b/backend/app/utils/listen/toolkit_listen.py @@ -36,7 +36,6 @@ def _safe_put_queue(task_lock, data): asyncio.set_event_loop(new_loop) try: new_loop.run_until_complete(task_lock.put_queue(data)) - logger.debug(f"[listen_toolkit] Successfully sent data to queue using new event loop") finally: new_loop.close() except Exception as e: @@ -96,7 +95,6 @@ def listen_toolkit( "message": args_str, }, ) - logger.debug(f"[listen_toolkit] Sending activate data: {activate_data.model_dump()}") await task_lock.put_queue(activate_data) error = None res = None @@ -134,7 +132,6 @@ def listen_toolkit( "message": res_msg, }, ) - logger.debug(f"[listen_toolkit] Sending deactivate data: {deactivate_data.model_dump()}") await task_lock.put_queue(deactivate_data) if error is not None: raise error @@ -181,12 +178,10 @@ def listen_toolkit( "message": args_str, }, ) - logger.debug(f"[listen_toolkit sync] Sending activate data: {activate_data.model_dump()}") _safe_put_queue(task_lock, activate_data) error = None res = None try: - logger.debug(f"Executing toolkit method: {toolkit_name}.{method_name} for agent '{toolkit.agent_name}'") res = func(*args, **kwargs) # Safety check: if the result is a coroutine, we need to await it if asyncio.iscoroutine(res): diff --git a/backend/app/utils/single_agent_worker.py b/backend/app/utils/single_agent_worker.py index e8008e53a..25fee7961 100644 --- a/backend/app/utils/single_agent_worker.py +++ b/backend/app/utils/single_agent_worker.py @@ -2,11 +2,13 @@ import datetime from camel.agents.chat_agent import AsyncStreamingChatAgentResponse from camel.societies.workforce.single_agent_worker import SingleAgentWorker as BaseSingleAgentWorker from camel.tasks.task import Task, TaskState, is_task_result_insufficient +from loguru import logger from app.utils.agent import ListenChatAgent from camel.societies.workforce.prompts import PROCESS_TASK_PROMPT from colorama import Fore from camel.societies.workforce.utils import TaskResult +from camel.utils.context_utils import ContextUtility class SingleAgentWorker(BaseSingleAgentWorker): @@ -19,6 +21,8 @@ class SingleAgentWorker(BaseSingleAgentWorker): pool_max_size: int = 10, auto_scale_pool: bool = True, use_structured_output_handler: bool = True, + context_utility: ContextUtility | None = None, + enable_workflow_memory: bool = False, ) -> None: super().__init__( description=description, @@ -28,6 +32,8 @@ class SingleAgentWorker(BaseSingleAgentWorker): pool_max_size=pool_max_size, auto_scale_pool=auto_scale_pool, use_structured_output_handler=use_structured_output_handler, + context_utility=context_utility, + enable_workflow_memory=enable_workflow_memory, ) self.worker = worker # change type hint @@ -130,8 +136,28 @@ class SingleAgentWorker(BaseSingleAgentWorker): usage_info = response.info.get("usage") or response.info.get("token_usage") total_tokens = usage_info.get("total_tokens", 0) if usage_info else 0 + # collect conversation from working agent to + # accumulator for workflow memory + # Only transfer memory if workflow memory is enabled + if self.enable_workflow_memory: + accumulator = self._get_conversation_accumulator() + + # transfer all memory records from working agent to accumulator + try: + # retrieve all context records from the working agent + work_records = worker_agent.memory.retrieve() + + # write these records to the accumulator's memory + memory_records = [record.memory_record for record in work_records] + accumulator.memory.write_records(memory_records) + + logger.debug(f"Transferred {len(memory_records)} memory records to accumulator") + + except Exception as e: + logger.warning(f"Failed to transfer conversation to accumulator: {e}") + except Exception as e: - print(f"{Fore.RED}Error processing task {task.id}: {type(e).__name__}: {e}{Fore.RESET}") + logger.error(f"Error processing task {task.id}: {type(e).__name__}: {e}") # Store error information in task result task.result = f"{type(e).__name__}: {e!s}" return TaskState.FAILED @@ -172,9 +198,12 @@ class SingleAgentWorker(BaseSingleAgentWorker): print(f"======\n{Fore.GREEN}Response from {self}:{Fore.RESET}") + logger.info(f"Response from {self}:") + if not self.use_structured_output_handler: # Handle native structured output parsing if task_result is None: + logger.error("Error in worker step execution: Invalid task result") print(f"{Fore.RED}Error in worker step execution: Invalid task result{Fore.RESET}") task_result = TaskResult( content="Failed to generate valid task result.", @@ -186,12 +215,17 @@ class SingleAgentWorker(BaseSingleAgentWorker): f"\n{color}{task_result.content}{Fore.RESET}\n======", # type: ignore[union-attr] ) + if task_result.failed: # type: ignore[union-attr] + logger.error(f"{task_result.content}") # type: ignore[union-attr] + else: + logger.info(f"{task_result.content}") # type: ignore[union-attr] + task.result = task_result.content # type: ignore[union-attr] if task_result.failed: # type: ignore[union-attr] return TaskState.FAILED if is_task_result_insufficient(task): - print(f"{Fore.RED}Task {task.id}: Content validation failed - task marked as failed{Fore.RESET}") + logger.warning(f"Task {task.id}: Content validation failed - task marked as failed") return TaskState.FAILED return TaskState.DONE diff --git a/backend/app/utils/toolkit/hybrid_browser_toolkit.py b/backend/app/utils/toolkit/hybrid_browser_toolkit.py index 94a288fe0..ed1e6d486 100644 --- a/backend/app/utils/toolkit/hybrid_browser_toolkit.py +++ b/backend/app/utils/toolkit/hybrid_browser_toolkit.py @@ -45,8 +45,13 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper): future.set_result(response) logger.debug(f"Processed response for message {message_id}") else: - # Log unexpected messages - logger.warning(f"Received unexpected message: {response}") + message_summary = { + "id": response.get("id"), + "success": response.get("success"), + "has_result": "result" in response, + "result_type": type(response.get("result")).__name__ if "result" in response else None + } + logger.debug(f"Received unexpected message: {message_summary}") except asyncio.CancelledError: disconnect_reason = "Receive loop cancelled" diff --git a/backend/app/utils/toolkit/notion_mcp_toolkit.py b/backend/app/utils/toolkit/notion_mcp_toolkit.py index 36928aa0a..735193083 100644 --- a/backend/app/utils/toolkit/notion_mcp_toolkit.py +++ b/backend/app/utils/toolkit/notion_mcp_toolkit.py @@ -6,6 +6,25 @@ from app.component.environment import env from app.utils.toolkit.abstract_toolkit import AbstractToolkit from camel.toolkits.mcp_toolkit import MCPToolkit +def _customize_function_parameters(schema: Dict[str, Any]) -> None: + r"""Customize function parameters for specific functions. + + This method allows modifying parameter descriptions or other schema + attributes for specific functions. + """ + function_info = schema.get("function", {}) + function_name = function_info.get("name", "") + parameters = function_info.get("parameters", {}) + properties = parameters.get("properties", {}) + required = parameters.get("required", []) + + # Modify the notion-create-pages function to make parent optional + if function_name == "notion-create-pages": + required.remove("parent") + parameters["required"] = required + if "parent" in properties: + # Update the parent parameter description + properties["parent"]["description"] = "Optional. " + properties["parent"]["description"] class NotionMCPToolkit(MCPToolkit, AbstractToolkit): @@ -33,68 +52,7 @@ class NotionMCPToolkit(MCPToolkit, AbstractToolkit): } } } - super().__init__(config_dict=config_dict, timeout=timeout) - - def get_tools(self) -> List[FunctionTool]: - r"""Returns a list of tools provided by the NotionMCPToolkit. - - Returns: - List[FunctionTool]: List of available tools. - """ - all_tools = [] - for client in self.clients: - try: - original_build_schema = client._build_tool_schema - - def create_wrapper(orig_func): - def wrapper(mcp_tool): - return self._build_custom_tool_schema( - mcp_tool, orig_func - ) - - return wrapper - - client._build_tool_schema = create_wrapper( # type: ignore[method-assign] - original_build_schema - ) - - client_tools = client.get_tools() - all_tools.extend(client_tools) - - client._build_tool_schema = original_build_schema # type: ignore[method-assign] - - except Exception as e: - logger.error(f"Failed to get tools from client: {e}") - return all_tools - - def _build_custom_tool_schema(self, mcp_tool, original_build_schema): - r"""Build tool schema with custom modifications.""" - schema = original_build_schema(mcp_tool) - self._customize_function_parameters(schema) - return schema - - def _customize_function_parameters(self, schema: Dict[str, Any]) -> None: - r"""Customize function parameters for specific functions. - - This method allows modifying parameter descriptions or other schema - attributes for specific functions. - """ - function_info = schema.get("function", {}) - function_name = function_info.get("name", "") - parameters = function_info.get("parameters", {}) - properties = parameters.get("properties", {}) - - # Modify the notion-create-pages function to make parent optional - if function_name == "notion-create-pages": - if "parent" in properties: - # Update the parent parameter description - properties["parent"]["description"] = ( - "Optional. The parent under which the new pages will be created. " - "This can be a page (page_id), a database page (database_id), or " - "a data source/collection under a database (data_source_id). " - "If omitted, the new pages will be created as private pages at the workspace level. " - "Use data_source_id when you have a collection:// URL from the fetch tool." - ) + super().__init__(config_dict=config_dict, timeout=timeout) @classmethod async def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]: @@ -104,6 +62,12 @@ class NotionMCPToolkit(MCPToolkit, AbstractToolkit): await toolkit.connect() # Use subclass implementation that inlines upstream processing all_tools = toolkit.get_tools() + tool_schema = [ + item.get_openai_tool_schema() for item in all_tools + ] + #adjust tool schema + for item in tool_schema: + _customize_function_parameters(item) for item in all_tools: setattr(item, "_toolkit_name", cls.__name__) tools.append(item) diff --git a/backend/app/utils/toolkit/search_toolkit.py b/backend/app/utils/toolkit/search_toolkit.py index 2da2fe13c..15146d7ce 100644 --- a/backend/app/utils/toolkit/search_toolkit.py +++ b/backend/app/utils/toolkit/search_toolkit.py @@ -51,19 +51,36 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit): @listen_toolkit( BaseSearchToolkit.search_google, - lambda _, query, search_type="web": f"with query '{query}' and {search_type} result pages", + lambda _, query, search_type="web", number_of_result_pages=10, start_page=1: f"with query '{query}', {search_type} type, {number_of_result_pages} result pages starting from page {start_page}", ) - def search_google(self, query: str, search_type: str = "web") -> list[dict[str, Any]]: + def search_google( + self, + query: str, + search_type: str = "web", + number_of_result_pages: int = 10, + start_page: int = 1 + ) -> list[dict[str, Any]]: if env("GOOGLE_API_KEY") and env("SEARCH_ENGINE_ID"): - return super().search_google(query, search_type) + return super().search_google(query, search_type, number_of_result_pages, start_page) else: - return self.cloud_search_google(query, search_type) + return self.cloud_search_google(query, search_type, number_of_result_pages, start_page) - def cloud_search_google(self, query: str, search_type): + def cloud_search_google( + self, + query: str, + search_type: str = "web", + number_of_result_pages: int = 10, + start_page: int = 1 + ): url = env_not_empty("SERVER_URL") res = httpx.get( url + "/proxy/google", - params={"query": query, "search_type": search_type}, + params={ + "query": query, + "search_type": search_type, + "number_of_result_pages": number_of_result_pages, + "start_page": start_page + }, headers={"api-key": env_not_empty("cloud_api_key")}, ) return res.json() @@ -164,73 +181,73 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit): # def search_bing(self, query: str) -> dict[str, Any]: # return super().search_bing(query) - @listen_toolkit(BaseSearchToolkit.search_exa, lambda _, query, *args, **kwargs: f"{query}, {args}, {kwargs}") - def search_exa( - self, - query: str, - search_type: Literal["auto", "neural", "keyword"] = "auto", - category: None - | Literal[ - "company", - "research paper", - "news", - "pdf", - "github", - "tweet", - "personal site", - "linkedin profile", - "financial report", - ] = None, - include_text: List[str] | None = None, - exclude_text: List[str] | None = None, - use_autoprompt: bool = True, - text: bool = False, - ) -> Dict[str, Any]: - if env("EXA_API_KEY"): - res = super().search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text) - return res - else: - return self.cloud_search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text) - - def cloud_search_exa( - self, - query: str, - search_type: Literal["auto", "neural", "keyword"] = "auto", - category: None - | Literal[ - "company", - "research paper", - "news", - "pdf", - "github", - "tweet", - "personal site", - "linkedin profile", - "financial report", - ] = None, - include_text: List[str] | None = None, - exclude_text: List[str] | None = None, - use_autoprompt: bool = True, - text: bool = False, - ): - url = env_not_empty("SERVER_URL") - logger.debug(f">>>>>>>>>>>>>>>>{url}<<<<") - res = httpx.post( - url + "/proxy/exa", - json={ - "query": query, - "search_type": search_type, - "category": category, - "include_text": include_text, - "exclude_text": exclude_text, - "use_autoprompt": use_autoprompt, - "text": text, - }, - headers={"api-key": env_not_empty("cloud_api_key")}, - ) - logger.debug(">>>>>>>>>>>>>>>>>") - logger.debug(res) - return res.json() + # @listen_toolkit(BaseSearchToolkit.search_exa, lambda _, query, *args, **kwargs: f"{query}, {args}, {kwargs}") + # def search_exa( + # self, + # query: str, + # search_type: Literal["auto", "neural", "keyword"] = "auto", + # category: None + # | Literal[ + # "company", + # "research paper", + # "news", + # "pdf", + # "github", + # "tweet", + # "personal site", + # "linkedin profile", + # "financial report", + # ] = None, + # include_text: List[str] | None = None, + # exclude_text: List[str] | None = None, + # use_autoprompt: bool = True, + # text: bool = False, + # ) -> Dict[str, Any]: + # if env("EXA_API_KEY"): + # res = super().search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text) + # return res + # else: + # return self.cloud_search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text) + # + # def cloud_search_exa( + # self, + # query: str, + # search_type: Literal["auto", "neural", "keyword"] = "auto", + # category: None + # | Literal[ + # "company", + # "research paper", + # "news", + # "pdf", + # "github", + # "tweet", + # "personal site", + # "linkedin profile", + # "financial report", + # ] = None, + # include_text: List[str] | None = None, + # exclude_text: List[str] | None = None, + # use_autoprompt: bool = True, + # text: bool = False, + # ): + # url = env_not_empty("SERVER_URL") + # logger.debug(f">>>>>>>>>>>>>>>>{url}<<<<") + # res = httpx.post( + # url + "/proxy/exa", + # json={ + # "query": query, + # "search_type": search_type, + # "category": category, + # "include_text": include_text, + # "exclude_text": exclude_text, + # "use_autoprompt": use_autoprompt, + # "text": text, + # }, + # headers={"api-key": env_not_empty("cloud_api_key")}, + # ) + # logger.debug(">>>>>>>>>>>>>>>>>") + # logger.debug(res) + # return res.json() # @listen_toolkit( # BaseSearchToolkit.search_alibaba_tongxiao, @@ -290,12 +307,12 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit): # if env("BOCHA_API_KEY"): # tools.append(FunctionTool(search_toolkit.search_bocha)) - if env("EXA_API_KEY") or env("cloud_api_key"): - tools.append(FunctionTool(search_toolkit.search_exa)) + # if env("EXA_API_KEY") or env("cloud_api_key"): + # tools.append(FunctionTool(search_toolkit.search_exa)) # if env("TONGXIAO_API_KEY"): # tools.append(FunctionTool(search_toolkit.search_alibaba_tongxiao)) return tools - def get_tools(self) -> List[FunctionTool]: - return [FunctionTool(self.search_exa)] + # def get_tools(self) -> List[FunctionTool]: + # return [FunctionTool(self.search_exa)] diff --git a/backend/app/utils/workforce.py b/backend/app/utils/workforce.py index 541110253..823a45406 100644 --- a/backend/app/utils/workforce.py +++ b/backend/app/utils/workforce.py @@ -85,7 +85,6 @@ class Workforce(BaseWorkforce): self.set_channel(TaskChannel()) self._state = WorkforceState.RUNNING task.state = TaskState.OPEN - self._pending_tasks.append(task) # Decompose the task into subtasks first subtasks_result = self._decompose_task(task) @@ -133,7 +132,9 @@ class Workforce(BaseWorkforce): # Find task content task_obj = get_camel_task(item.task_id, tasks) if task_obj is None: - logger.warning(f"[WF] WARN: Task {item.task_id} not found in tasks list during ASSIGN phase. This may indicate a task tree inconsistency.") + logger.warning( + f"[WF] WARN: Task {item.task_id} not found in tasks list during ASSIGN phase. This may indicate a task tree inconsistency." + ) content = "" else: content = task_obj.content @@ -179,7 +180,11 @@ class Workforce(BaseWorkforce): await super()._post_task(task, assignee_id) def add_single_agent_worker( - self, description: str, worker: ListenChatAgent, pool_max_size: int = DEFAULT_WORKER_POOL_SIZE + self, + description: str, + worker: ListenChatAgent, + pool_max_size: int = DEFAULT_WORKER_POOL_SIZE, + enable_workflow_memory: bool = False, ) -> BaseWorkforce: if self._state == WorkforceState.RUNNING: raise RuntimeError("Cannot add workers while workforce is running. Pause the workforce first.") @@ -195,6 +200,8 @@ class Workforce(BaseWorkforce): worker=worker, pool_max_size=pool_max_size, use_structured_output_handler=self.use_structured_output_handler, + context_utility=None, # Will be set during save/load operations + enable_workflow_memory=enable_workflow_memory, ) self._children.append(worker_node) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 05049886c..dfda79208 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,7 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = "==3.10.16" dependencies = [ - "camel-ai[eigent]==0.2.76a13", + "camel-ai[eigent]==0.2.78", "fastapi>=0.115.12", "fastapi-babel>=1.0.0", "uvicorn[standard]>=0.34.2", diff --git a/backend/uv.lock b/backend/uv.lock index cfe0b2776..2b5acf932 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -122,6 +122,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" }, ] +[[package]] +name = "astor" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/21/75b771132fee241dfe601d39ade629548a9626d1d39f333fde31bc46febe/astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e", size = 35090, upload-time = "2019-12-10T01:50:35.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/88/97eef84f48fa04fbd6750e62dcceafba6c63c81b7ac1420856c8dcc0a3f9/astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5", size = 27488, upload-time = "2019-12-10T01:50:33.628Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -240,7 +249,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, - { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.76a13" }, + { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.78" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi-babel", specifier = ">=1.0.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, @@ -324,9 +333,10 @@ wheels = [ [[package]] name = "camel-ai" -version = "0.2.76a13" +version = "0.2.78" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "astor" }, { name = "colorama" }, { name = "docstring-parser" }, { name = "httpx" }, @@ -339,9 +349,9 @@ dependencies = [ { name = "tiktoken" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/7c/0145edf0307e360557917de28691eb0c41b36b017a28c6b67e58a729a6da/camel_ai-0.2.76a13.tar.gz", hash = "sha256:487570c36a39a333ae8000783babd5a82350a829aaa8aa2ae712470b596cafe1", size = 950278, upload-time = "2025-10-06T06:09:46.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/2b/cd5181bfd0ebcf567a088ee5c1e3768b132ba4b1489ee19d5fb0bd679586/camel_ai-0.2.78.tar.gz", hash = "sha256:24745da225da7da96dcd85f72d143c6104569c17f14280c369d7e82b86851284", size = 964632, upload-time = "2025-10-15T17:20:54.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/46/9886106669491737631178830bce79bd7bf63391db4d2200f645089dd9df/camel_ai-0.2.76a13-py3-none-any.whl", hash = "sha256:b860412e4a5b5fc31b0cc3d4b1eeefcd02382d9a5aced252856a1eff0285a97b", size = 1400549, upload-time = "2025-10-06T06:09:43.291Z" }, + { url = "https://files.pythonhosted.org/packages/01/81/0cfb1c0d9da589665e2eb4471887967e70bba428638c37fb4f6a78baf300/camel_ai-0.2.78-py3-none-any.whl", hash = "sha256:356624da13dfe0c55ef43dc509c18ce029f67fe3997966495a4ce9be931078d5", size = 1415578, upload-time = "2025-10-15T17:20:51.727Z" }, ] [package.optional-dependencies] diff --git a/electron/main/index.ts b/electron/main/index.ts index 682f3d8ca..09d516e45 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -613,7 +613,7 @@ function registerIpcHandlers() { const stats = await fsp.stat(filePath); if (stats.isDirectory()) { log.error('Path is a directory, not a file:', filePath); - return { success: false, error: 'EISDIR: illegal operation on a directory, read' }; + return { success: false, error: 'Path is a directory, not a file' }; } // Read file content diff --git a/electron/main/webview.ts b/electron/main/webview.ts index 8036dda6d..8e686e6eb 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -64,6 +64,9 @@ export class WebViewManager { } const view = new WebContentsView({ webPreferences: { + // Use a separate session partition for webviews to isolate storage from main window + // This ensures clearing webview storage won't affect main window's auth data + partition: 'persist:agent-webview', nodeIntegration: false, contextIsolation: true, backgroundThrottling: true, @@ -269,6 +272,7 @@ export class WebViewManager { if (!webViewInfo.view.webContents.isDestroyed()) { webViewInfo.view.webContents.removeAllListeners() + // Now safe to clear all storage since webviews use separate partition webViewInfo.view.webContents.session.clearCache() webViewInfo.view.webContents.session.clearStorageData({ storages: ['cookies', 'localstorage', 'websql', 'indexdb', 'serviceworkers', 'cachestorage'] diff --git a/server/alembic/versions/2025_10_15_1446-eec7242b3a9b_modify_chat_history_add_project_id.py b/server/alembic/versions/2025_10_15_1446-eec7242b3a9b_modify_chat_history_add_project_id.py new file mode 100644 index 000000000..aaa7626dc --- /dev/null +++ b/server/alembic/versions/2025_10_15_1446-eec7242b3a9b_modify_chat_history_add_project_id.py @@ -0,0 +1,36 @@ +"""modify_chat_history_add_project_id + +Revision ID: eec7242b3a9b +Revises: d74ab2a44600 +Create Date: 2025-10-15 14:46:47.904254 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision: str = "eec7242b3a9b" +down_revision: Union[str, None] = "d74ab2a44600" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("chat_history", sa.Column("project_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.create_index(op.f("ix_chat_history_project_id"), "chat_history", ["project_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_chat_history_project_id"), table_name="chat_history") + op.drop_column("chat_history", "project_id") + # ### end Alembic commands ### diff --git a/server/app/model/chat/chat_history.py b/server/app/model/chat/chat_history.py index e351e34ae..6dd7775a1 100644 --- a/server/app/model/chat/chat_history.py +++ b/server/app/model/chat/chat_history.py @@ -4,7 +4,7 @@ from typing import Optional from enum import IntEnum from sqlalchemy_utils import ChoiceType from app.model.abstract.model import AbstractModel, DefaultTimes -from pydantic import BaseModel +from pydantic import BaseModel, model_validator class ChatStatus(IntEnum): @@ -16,6 +16,7 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True): id: int = Field(default=None, primary_key=True) user_id: int = Field(index=True) task_id: str = Field(index=True, unique=True) + project_id: str = Field(index=True, unique=False, nullable=True) question: str language: str model_platform: str @@ -34,6 +35,7 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True): class ChatHistoryIn(BaseModel): task_id: str + project_id: str | None = None user_id: int | None = None question: str language: str @@ -54,6 +56,7 @@ class ChatHistoryIn(BaseModel): class ChatHistoryOut(BaseModel): id: int task_id: str + project_id: str | None = None question: str language: str model_platform: str @@ -68,9 +71,17 @@ class ChatHistoryOut(BaseModel): tokens: int status: int + @model_validator(mode="after") + def fill_project_id_from_task_id(self): + """fill by task_id when project_id is None""" + if self.project_id is None: + self.project_id = self.task_id + return self + class ChatHistoryUpdate(BaseModel): project_name: str | None = None summary: str | None = None tokens: int | None = None status: int | None = None + project_id: str | None = None diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 45d29ee6a..ee73bf1d6 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -294,10 +294,10 @@ export default function ChatBox(): JSX.Element { onTyping={scrollToBottom} />
- {item.fileList?.map((file) => { + {item.fileList?.map((file, fileIndex) => { return (
{ // set selected file chatStore.setSelectedFile( @@ -376,10 +376,10 @@ export default function ChatBox(): JSX.Element { onTyping={scrollToBottom} /> */}
- {item.fileList?.map((file) => { + {item.fileList?.map((file, fileIndex) => { return (
{ // set selected file chatStore.setSelectedFile( diff --git a/src/components/WorkFlow/index.tsx b/src/components/WorkFlow/index.tsx index 9f0bdc564..42c013243 100644 --- a/src/components/WorkFlow/index.tsx +++ b/src/components/WorkFlow/index.tsx @@ -31,12 +31,14 @@ const nodeTypes: NodeTypes = { node: (props: any) => , }; +const VIEWPORT_ANIMATION_DURATION = 500; + export default function Workflow({ taskAssigning, }: { taskAssigning: Agent[]; }) { - const {t} = useTranslation(); + const { t } = useTranslation(); const chatStore = useChatStore(); const [isEditMode, setIsEditMode] = useState(false); const [lastViewport, setLastViewport] = useState({ x: 0, y: 0, zoom: 1 }); @@ -245,7 +247,7 @@ export default function Workflow({ }, position: isEditMode ? node.position - : { x: index * (342+20) + 8, y: 16 }, + : { x: index * (342 + 20) + 8, y: 16 }, }; } else { return { @@ -259,7 +261,7 @@ export default function Workflow({ isEditMode: isEditMode, workerInfo: agent?.workerInfo, }, - position: { x: index * (342+20) + 8, y: 16 }, + position: { x: index * (342 + 20) + 8, y: 16 }, type: "node", }; } @@ -293,6 +295,24 @@ export default function Workflow({ }; }, [getViewport, setViewport, isEditMode]); + const [isAnimating, setIsAnimating] = useState(false); + const moveViewport = (dx: number) => { + if (isAnimating) return; + const viewport = getViewport(); + setIsAnimating(true); + // Prevent scrolling past x=0 (too far right) when moving left + const newX = dx > 0 ? Math.min(0, viewport.x + dx) : viewport.x + dx; + setViewport( + { x: newX, y: viewport.y, zoom: viewport.zoom }, + { + duration: VIEWPORT_ANIMATION_DURATION, + } + ); + setTimeout(() => { + setIsAnimating(false); + }, VIEWPORT_ANIMATION_DURATION); + }; + const handleShare = async (taskId: string) => { share(taskId); }; @@ -343,12 +363,7 @@ export default function Workflow({ variant="ghost" size="icon" onClick={() => { - const viewport = getViewport(); - const newX = Math.min(0, viewport.x + 200); - setViewport( - { x: newX, y: viewport.y, zoom: viewport.zoom }, - { duration: 500 } - ); + moveViewport(200); }} > @@ -356,14 +371,7 @@ export default function Workflow({ diff --git a/src/pages/Setting/Models.tsx b/src/pages/Setting/Models.tsx index 837fd934b..b16a85327 100644 --- a/src/pages/Setting/Models.tsx +++ b/src/pages/Setting/Models.tsx @@ -622,6 +622,8 @@ export default function SettingModels() { ? t("setting.gpt-4.1") : cloud_model_type === "claude-sonnet-4-5" ? t("setting.claude-sonnet-4-5") + : cloud_model_type === "claude-sonnet-4-20250514" + ? t("setting.claude-sonnet-4") : cloud_model_type === "claude-3-5-haiku-20241022" ? t("setting.claude-3.5-haiku") : cloud_model_type === "gpt-5" @@ -658,6 +660,9 @@ export default function SettingModels() { Claude Sonnet 4-5 + + Claude Sonnet 4 + Claude 3.5 Haiku diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 63850e82e..0db51c025 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware'; // type definition type InitState = 'permissions' | 'carousel' | 'done'; type ModelType = 'cloud' | 'local' | 'custom'; -type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano'; +type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano'; // auth info interface interface AuthInfo { diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 2e89f8d7b..7b37f6af9 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -235,7 +235,9 @@ const chatStore = create()( apiModel = { api_key: res.value, model_type: cloud_model_type, - model_platform: cloud_model_type.includes('gpt') ? 'openai' : 'gemini', + model_platform: cloud_model_type.includes('gpt') ? 'openai' : + cloud_model_type.includes('claude') ? 'anthropic' : + cloud_model_type.includes('gemini') ? 'gemini' : 'openai-compatible-model', api_url: res.api_url, extra_params: {} } @@ -885,12 +887,9 @@ const chatStore = create()( taskId as string ); if (!type && import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && res.length > 0) { - // Filter out directories, only upload actual files - const files = res.filter((file: any) => !file.isFolder); - // Upload files sequentially to avoid overwhelming the server const uploadResults = await Promise.allSettled( - files.map(async (file: any) => { + res.filter((file: any) => !file.isFolder).map(async (file: any) => { try { // Read file content using Electron API const result = await window.ipcRenderer.invoke('read-file', file.path); diff --git a/vite.config.ts b/vite.config.ts index 442d027c6..9096a9986 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -67,22 +67,25 @@ export default defineConfig(({ command, mode }) => { renderer: {}, }), ], - server: process.env.VSCODE_DEBUG && (() => { - const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL) - return { - host: url.hostname, - port: +url.port, - proxy: { - '/api': { - target: env.VITE_PROXY_URL, - changeOrigin: true, - // rewrite: path => path.replace(/^\/api/, ''), + server: { + open: false, + ...(process.env.VSCODE_DEBUG && (() => { + const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL) + return { + host: url.hostname, + port: +url.port, + proxy: { + '/api': { + target: env.VITE_PROXY_URL, + changeOrigin: true, + // rewrite: path => path.replace(/^\/api/, ''), + }, }, - }, - } - })(), - clearScreen: false, + } + })()), + clearScreen: false, + } } })