From b1e2ac97f13f02176acffaee30088ab7ad3523a4 Mon Sep 17 00:00:00 2001 From: bytecii Date: Wed, 4 Mar 2026 23:42:38 -0800 Subject: [PATCH 1/4] refactor: refactor the chat service --- backend/app/agent/__init__.py | 12 + backend/app/agent/factory/__init__.py | 22 +- backend/app/agent/factory/question_confirm.py | 74 + backend/app/agent/factory/task_summary.py | 131 + backend/app/agent/factory/workforce_agents.py | 140 + backend/app/service/chat_service.py | 2428 ----------------- backend/app/service/chat_service/__init__.py | 61 + .../app/service/chat_service/_step_solve.py | 293 ++ backend/app/service/chat_service/context.py | 80 + .../app/service/chat_service/decomposition.py | 272 ++ backend/app/service/chat_service/lifecycle.py | 1048 +++++++ backend/app/service/chat_service/router.py | 573 ++++ backend/app/utils/context.py | 242 ++ .../tests/app/service/test_chat_service.py | 59 +- 14 files changed, 2985 insertions(+), 2450 deletions(-) create mode 100644 backend/app/agent/factory/workforce_agents.py delete mode 100644 backend/app/service/chat_service.py create mode 100644 backend/app/service/chat_service/__init__.py create mode 100644 backend/app/service/chat_service/_step_solve.py create mode 100644 backend/app/service/chat_service/context.py create mode 100644 backend/app/service/chat_service/decomposition.py create mode 100644 backend/app/service/chat_service/lifecycle.py create mode 100644 backend/app/service/chat_service/router.py create mode 100644 backend/app/utils/context.py diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py index 264103bba..04a7aa903 100644 --- a/backend/app/agent/__init__.py +++ b/backend/app/agent/__init__.py @@ -15,12 +15,18 @@ from app.agent.agent_model import agent_model from app.agent.factory import ( browser_agent, + create_coordinator_and_task_agents, + create_new_worker_agent, developer_agent, document_agent, + get_task_result_with_optional_summary, mcp_agent, multi_modal_agent, + question_confirm, question_confirm_agent, social_media_agent, + summary_subtasks_result, + summary_task, task_summary_agent, ) from app.agent.listen_chat_agent import ListenChatAgent @@ -32,11 +38,17 @@ "get_mcp_tools", "get_toolkits", "browser_agent", + "create_coordinator_and_task_agents", + "create_new_worker_agent", "developer_agent", "document_agent", + "get_task_result_with_optional_summary", "mcp_agent", "multi_modal_agent", + "question_confirm", "question_confirm_agent", "social_media_agent", + "summary_subtasks_result", + "summary_task", "task_summary_agent", ] diff --git a/backend/app/agent/factory/__init__.py b/backend/app/agent/factory/__init__.py index ef74a818d..9193270a9 100644 --- a/backend/app/agent/factory/__init__.py +++ b/backend/app/agent/factory/__init__.py @@ -17,17 +17,35 @@ from app.agent.factory.document import document_agent from app.agent.factory.mcp import mcp_agent from app.agent.factory.multi_modal import multi_modal_agent -from app.agent.factory.question_confirm import question_confirm_agent +from app.agent.factory.question_confirm import ( + question_confirm, + question_confirm_agent, +) from app.agent.factory.social_media import social_media_agent -from app.agent.factory.task_summary import task_summary_agent +from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + summary_subtasks_result, + summary_task, + task_summary_agent, +) +from app.agent.factory.workforce_agents import ( + create_coordinator_and_task_agents, + create_new_worker_agent, +) __all__ = [ "browser_agent", + "create_coordinator_and_task_agents", + "create_new_worker_agent", "developer_agent", "document_agent", + "get_task_result_with_optional_summary", "mcp_agent", "multi_modal_agent", + "question_confirm", "question_confirm_agent", "social_media_agent", + "summary_subtasks_result", + "summary_task", "task_summary_agent", ] diff --git a/backend/app/agent/factory/question_confirm.py b/backend/app/agent/factory/question_confirm.py index 3461f5238..8642f684d 100644 --- a/backend/app/agent/factory/question_confirm.py +++ b/backend/app/agent/factory/question_confirm.py @@ -12,10 +12,22 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + from app.agent.agent_model import agent_model from app.agent.prompt import QUESTION_CONFIRM_SYS_PROMPT from app.agent.utils import NOW_STR from app.model.chat import Chat +from app.utils.context import build_conversation_context + +if TYPE_CHECKING: + from app.agent.listen_chat_agent import ListenChatAgent + from app.service.task import TaskLock + +logger = logging.getLogger("chat_service") def question_confirm_agent(options: Chat): @@ -24,3 +36,65 @@ def question_confirm_agent(options: Chat): QUESTION_CONFIRM_SYS_PROMPT.format(now_str=NOW_STR), options, ) + + +async def question_confirm( + agent: ListenChatAgent, prompt: str, task_lock: TaskLock | None = None +) -> bool: + """Simple question confirmation - returns True + for complex tasks, False for simple questions.""" + + context_prompt = "" + if task_lock: + context_prompt = build_conversation_context( + task_lock, header="=== Previous Conversation ===" + ) + + full_prompt = f"""{context_prompt}User Query: {prompt} + +Determine if this user query is a complex task or a simple question. + +**Complex task** (answer "yes"): Requires tools, code execution, \ +file operations, multi-step planning, or creating/modifying content +- Examples: "create a file", "search for X", \ +"implement feature Y", "write code", "analyze data" + +**Simple question** (answer "no"): Can be answered directly \ +with knowledge or conversation history, no action needed +- Examples: greetings ("hello", "hi"), \ +fact queries ("what is X?"), clarifications, status checks + +Answer only "yes" or "no". Do not provide any explanation. + +Is this a complex task? (yes/no):""" + + try: + resp = agent.step(full_prompt) + + if not resp or not resp.msgs or len(resp.msgs) == 0: + logger.warning( + "No response from agent, defaulting to complex task" + ) + return True + + content = resp.msgs[0].content + if not content: + logger.warning( + "Empty content from agent, defaulting to complex task" + ) + return True + + normalized = content.strip().lower() + is_complex = "yes" in normalized + + result_str = "complex task" if is_complex else "simple question" + logger.info( + f"Question confirm result: {result_str}", + extra={"response": content, "is_complex": is_complex}, + ) + + return is_complex + + except Exception as e: + logger.error(f"Error in question_confirm: {e}") + raise diff --git a/backend/app/agent/factory/task_summary.py b/backend/app/agent/factory/task_summary.py index e0b05afaf..78bfed496 100644 --- a/backend/app/agent/factory/task_summary.py +++ b/backend/app/agent/factory/task_summary.py @@ -12,10 +12,22 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from camel.tasks import Task + from app.agent.agent_model import agent_model from app.agent.prompt import TASK_SUMMARY_SYS_PROMPT from app.model.chat import Chat +if TYPE_CHECKING: + from app.agent.listen_chat_agent import ListenChatAgent + +logger = logging.getLogger("chat_service") + def task_summary_agent(options: Chat): return agent_model( @@ -23,3 +35,122 @@ def task_summary_agent(options: Chat): TASK_SUMMARY_SYS_PROMPT, options, ) + + +async def summary_task(agent: ListenChatAgent, task: Task) -> str: + prompt = f"""The user's task is: +--- +{task.to_string()} +--- +Your instructions are: +1. Come up with a short and descriptive name for this task. +2. Create a concise summary of the task's main points and objectives. +3. Return the task name and the summary, separated by a vertical bar (|). + +Example format: "Task Name|This is the summary of the task." +Do not include any other text or formatting. +""" + logger.debug("Generating task summary", extra={"task_id": task.id}) + try: + res = agent.step(prompt) + summary = res.msgs[0].content + logger.info("Task summary generated", extra={"summary": summary}) + return summary + except Exception as e: + logger.error( + "Error generating task summary", + extra={"error": str(e)}, + exc_info=True, + ) + raise + + +async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: + """ + Summarize the aggregated results from all subtasks into a concise summary. + + Args: + agent: The summary agent to use + task: The main task containing subtasks and their aggregated results + + Returns: + A concise summary of all subtask results + """ + subtasks_info = "" + for i, subtask in enumerate(task.subtasks, 1): + subtasks_info += f"\n**Subtask {i}**\n" + subtasks_info += f"Description: {subtask.content}\n" + subtasks_info += f"Result: {subtask.result or 'No result'}\n" + subtasks_info += "---\n" + + prompt = f"""You are a professional summarizer. \ +Summarize the results of the following subtasks. + +Main Task: {task.content} + +Subtasks (with descriptions and results): +--- +{subtasks_info} +--- + +Instructions: +1. Provide a concise summary of what was accomplished +2. Highlight key findings or outputs from each subtask +3. Mention any important files created or actions taken +4. Use bullet points or sections for clarity +5. DO NOT repeat the task name in your summary - go straight to the results +6. Keep it professional but conversational + +Summary: +""" + + res = agent.step(prompt) + summary = res.msgs[0].content + + logger.info( + "Generated subtasks summary for " + f"task {task.id} with " + f"{len(task.subtasks)} subtasks" + ) + + return summary + + +async def get_task_result_with_optional_summary( + task: Task, options: Chat +) -> str: + """ + Get the task result, with LLM summary if there are multiple subtasks. + + Args: + task: The task to get result from + options: Chat options for creating summary agent + + Returns: + The task result (summarized if multiple subtasks, raw otherwise) + """ + result = str(task.result or "") + + if task.subtasks and len(task.subtasks) > 1: + logger.info( + f"Task {task.id} has " + f"{len(task.subtasks)} subtasks, " + "generating summary" + ) + try: + summary_agent = task_summary_agent(options) + summarized_result = await summary_subtasks_result( + summary_agent, task + ) + result = summarized_result + logger.info(f"Successfully generated summary for task {task.id}") + except Exception as e: + logger.error(f"Failed to generate summary for task {task.id}: {e}") + elif task.subtasks and len(task.subtasks) == 1: + logger.info(f"Task {task.id} has only 1 subtask, skipping LLM summary") + if result and "--- Subtask" in result and "Result ---" in result: + parts = result.split("Result ---", 1) + if len(parts) > 1: + result = parts[1].strip() + + return result diff --git a/backend/app/agent/factory/workforce_agents.py b/backend/app/agent/factory/workforce_agents.py new file mode 100644 index 000000000..ce8dad429 --- /dev/null +++ b/backend/app/agent/factory/workforce_agents.py @@ -0,0 +1,140 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Factory functions for workforce-internal agents (coordinator, task, worker).""" + +from __future__ import annotations + +import datetime +import platform +from typing import TYPE_CHECKING + +from camel.toolkits import ToolkitMessageIntegration + +from app.agent.agent_model import agent_model +from app.agent.toolkit.human_toolkit import HumanToolkit +from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit +from app.model.chat import Chat +from app.service.task import Agents + +if TYPE_CHECKING: + from app.agent.listen_chat_agent import ListenChatAgent + + +def create_coordinator_and_task_agents( + options: Chat, working_directory: str +) -> list[ListenChatAgent]: + """Create coordinator and task agents (sync, runs in thread pool).""" + return [ + agent_model( + key, + prompt, + options, + [ + *( + ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, key + ).send_message_to_user + ).register_toolkits( + NoteTakingToolkit( + options.project_id, + working_directory=working_directory, + ) + ) + ).get_tools(), + *SkillToolkit( + options.project_id, + key, + working_directory=working_directory, + user_id=options.skill_config_user_id(), + ).get_tools(), + ], + ) + for key, prompt in { + Agents.coordinator_agent: f""" +You are a helpful coordinator. +- You are now working in system {platform.system()} with architecture +{platform.machine()} at working directory \ +`{working_directory}`. All local file operations \ +must occur here, but you can access files from any \ +place in the file system. For all file system \ +operations, you MUST use absolute paths to ensure \ +precision and avoid ambiguity. +The current date is {datetime.date.today()}. \ +For any date-related tasks, you MUST use this as \ +the current date. + """, + Agents.task_agent: f""" +You are a helpful task planner. +- You are now working in system {platform.system()} with architecture +{platform.machine()} at working directory \ +`{working_directory}`. All local file operations \ +must occur here, but you can access files from any \ +place in the file system. For all file system \ +operations, you MUST use absolute paths to ensure \ +precision and avoid ambiguity. +The current date is {datetime.date.today()}. \ +For any date-related tasks, you MUST use this as \ +the current date. + """, + }.items() + ] + + +def create_new_worker_agent( + options: Chat, working_directory: str +) -> ListenChatAgent: + """Create new worker agent (sync, runs in thread pool).""" + return agent_model( + Agents.new_worker_agent, + f""" + You are a helpful assistant. +- You are now working in system {platform.system()} with architecture +{platform.machine()} at working directory \ +`{working_directory}`. All local file operations \ +must occur here, but you can access files from any \ +place in the file system. For all file system \ +operations, you MUST use absolute paths to ensure \ +precision and avoid ambiguity. +The current date is {datetime.date.today()}. \ +For any date-related tasks, you MUST use this as \ +the current date. + """, + options, + [ + *HumanToolkit.get_can_use_tools( + options.project_id, Agents.new_worker_agent + ), + *( + ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, Agents.new_worker_agent + ).send_message_to_user + ).register_toolkits( + NoteTakingToolkit( + options.project_id, + working_directory=working_directory, + ) + ) + ).get_tools(), + *SkillToolkit( + options.project_id, + Agents.new_worker_agent, + working_directory=working_directory, + user_id=options.skill_config_user_id(), + ).get_tools(), + ], + ) diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py deleted file mode 100644 index 41c912ab6..000000000 --- a/backend/app/service/chat_service.py +++ /dev/null @@ -1,2428 +0,0 @@ -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - -import asyncio -import datetime -import logging -import platform -from pathlib import Path -from typing import Any - -from camel.models import ModelProcessingError -from camel.tasks import Task -from camel.toolkits import ToolkitMessageIntegration -from camel.types import ModelPlatformType -from fastapi import Request -from inflection import titleize -from pydash import chain - -from app.agent.agent_model import agent_model -from app.agent.factory import ( - browser_agent, - developer_agent, - document_agent, - mcp_agent, - multi_modal_agent, - question_confirm_agent, - task_summary_agent, -) -from app.agent.listen_chat_agent import ListenChatAgent -from app.agent.toolkit.human_toolkit import HumanToolkit -from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit -from app.agent.toolkit.skill_toolkit import SkillToolkit -from app.agent.toolkit.terminal_toolkit import TerminalToolkit -from app.agent.tools import get_mcp_tools, get_toolkits -from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json -from app.service.task import ( - Action, - ActionDecomposeProgressData, - ActionDecomposeTextData, - ActionImproveData, - ActionInstallMcpData, - ActionNewAgent, - Agents, - TaskLock, - delete_task_lock, - set_current_task_id, -) -from app.utils.event_loop_utils import set_main_event_loop -from app.utils.file_utils import get_working_directory, list_files -from app.utils.server.sync_step import sync_step -from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback -from app.utils.workforce import Workforce - -logger = logging.getLogger("chat_service") - - -def format_task_context( - task_data: dict, seen_files: set | None = None, skip_files: bool = False -) -> str: - """Format structured task data into a readable context string. - - Args: - task_data: Dictionary containing task content, result, - and working directory - seen_files: Optional set to track already-listed files - and avoid duplicates (deprecated, use skip_files - instead) - skip_files: If True, skip the file listing entirely - """ - context_parts = [] - - if task_data.get("task_content"): - context_parts.append(f"Previous Task: {task_data['task_content']}") - - if task_data.get("task_result"): - context_parts.append( - f"Previous Task Result: {task_data['task_result']}" - ) - - # Skip file listing if requested - if not skip_files: - working_directory = task_data.get("working_directory") - if working_directory: - try: - generated_files = list_files( - working_directory, - base=working_directory, - skip_dirs={"node_modules", "__pycache__", "venv"}, - skip_extensions=(".pyc", ".tmp"), - skip_prefix=".", - ) - if seen_files is not None: - generated_files = [ - p for p in generated_files if p not in seen_files - ] - seen_files.update(generated_files) - if generated_files: - context_parts.append("Generated Files from Previous Task:") - for file_path in sorted(generated_files): - context_parts.append(f" - {file_path}") - except Exception as e: - logger.warning(f"Failed to collect generated files: {e}") - - return "\n".join(context_parts) - - -def collect_previous_task_context( - working_directory: str, - previous_task_content: str, - previous_task_result: str, - previous_summary: str = "", -) -> str: - """ - Collect context from previous task including content, result, - summary, and generated files. - - Args: - working_directory: The working directory to scan for generated files - previous_task_content: The content of the previous task - previous_task_result: The result/output of the previous task - previous_summary: The summary of the previous task - - Returns: - Formatted context string to prepend to new task - """ - - context_parts = [] - - # Add previous task information - context_parts.append("=== CONTEXT FROM PREVIOUS TASK ===\n") - - # Add previous task content - if previous_task_content: - context_parts.append(f"Previous Task:\n{previous_task_content}\n") - - # Add previous task summary - if previous_summary: - context_parts.append(f"Previous Task Summary:\n{previous_summary}\n") - - # Add previous task result - if previous_task_result: - context_parts.append( - f"Previous Task Result:\n{previous_task_result}\n" - ) - - # Collect generated files from working directory (safe listing) - try: - generated_files = list_files( - working_directory, - base=working_directory, - skip_dirs={"node_modules", "__pycache__", "venv"}, - skip_extensions=(".pyc", ".tmp"), - skip_prefix=".", - ) - if generated_files: - context_parts.append("Generated Files from Previous Task:") - for file_path in sorted(generated_files): - context_parts.append(f" - {file_path}") - context_parts.append("") - except Exception as e: - logger.warning(f"Failed to collect generated files: {e}") - - context_parts.append("=== END OF PREVIOUS TASK CONTEXT ===\n") - - return "\n".join(context_parts) - - -def check_conversation_history_length( - task_lock: TaskLock, max_length: int = 200000 -) -> tuple[bool, int]: - """ - Check if conversation history exceeds maximum length - - Returns: - tuple: (is_exceeded, total_length) - """ - if ( - not hasattr(task_lock, "conversation_history") - or not task_lock.conversation_history - ): - return False, 0 - - total_length = 0 - for entry in task_lock.conversation_history: - total_length += len(entry.get("content", "")) - - is_exceeded = total_length > max_length - - if is_exceeded: - logger.warning( - f"Conversation history length {total_length} " - f"exceeds maximum {max_length}" - ) - - return is_exceeded, total_length - - -def build_conversation_context( - task_lock: TaskLock, header: str = "=== CONVERSATION HISTORY ===" -) -> str: - """Build conversation context from task_lock history - with files listed only once at the end. - - Args: - task_lock: TaskLock containing conversation history - header: Header text for the context section - - Returns: - Formatted context string with task history - and files listed once at the end - """ - context = "" - working_directories = set() # Collect all unique working directories - - if task_lock.conversation_history: - context = f"{header}\n" - - for entry in task_lock.conversation_history: - if entry["role"] == "task_result": - if isinstance(entry["content"], dict): - formatted_context = format_task_context( - entry["content"], skip_files=True - ) - context += formatted_context + "\n\n" - if entry["content"].get("working_directory"): - working_directories.add( - entry["content"]["working_directory"] - ) - else: - context += entry["content"] + "\n" - elif entry["role"] == "assistant": - context += f"Assistant: {entry['content']}\n\n" - - if working_directories: - all_generated_files: set[str] = set() - for working_directory in working_directories: - try: - files_list = list_files( - working_directory, - base=working_directory, - skip_dirs={"node_modules", "__pycache__", "venv"}, - skip_extensions=(".pyc", ".tmp"), - skip_prefix=".", - ) - all_generated_files.update(files_list) - except Exception as e: - logger.warning( - "Failed to collect generated " - f"files from {working_directory}: {e}" - ) - - if all_generated_files: - context += "Generated Files from Previous Tasks:\n" - for file_path in sorted(all_generated_files): - context += f" - {file_path}\n" - context += "\n" - - context += "\n" - - return context - - -def build_context_for_workforce( - task_lock: TaskLock, - options: Chat, - task_content: str | None = None, -) -> str: - """Build context information for workforce. - Instructs coordinator to actively load skills using list_skills/load_skill tools. - """ - return build_conversation_context( - task_lock, header="=== CONVERSATION HISTORY ===" - ) - - -@sync_step -async def step_solve(options: Chat, request: Request, task_lock: TaskLock): - """Main task execution loop. Called when POST /chat endpoint - is hit to start a new chat session. - - Processes task queue, manages workforce lifecycle, and streams - responses back to the client via SSE. - - Args: - options (Chat): Chat configuration containing task details and - model settings. - request (Request): FastAPI request object for client connection - management. - task_lock (TaskLock): Shared task state and queue for the project. - - Yields: - SSE formatted responses for task progress, errors, and results - """ - start_event_loop = True - - # Initialize task_lock attributes - if not hasattr(task_lock, "conversation_history"): - task_lock.conversation_history = [] - if not hasattr(task_lock, "last_task_result"): - task_lock.last_task_result = "" - if not hasattr(task_lock, "question_agent"): - task_lock.question_agent = None - if not hasattr(task_lock, "summary_generated"): - task_lock.summary_generated = False - - # Create or reuse persistent question_agent - if task_lock.question_agent is None: - task_lock.question_agent = question_confirm_agent(options) - else: - hist_len = len(task_lock.conversation_history) - logger.debug( - f"Reusing existing question_agent with {hist_len} history entries" - ) - - question_agent = task_lock.question_agent - - # Other variables - camel_task = None - workforce = None - mcp = None - last_completed_task_result = "" # Track the last completed task result - summary_task_content = "" # Track task summary - loop_iteration = 0 - event_loop = asyncio.get_running_loop() - sub_tasks: list[Task] = [] - - logger.info("=" * 80) - logger.info( - "🚀 [LIFECYCLE] step_solve STARTED", - extra={"project_id": options.project_id, "task_id": options.task_id}, - ) - logger.info("=" * 80) - logger.debug( - "Step solve options", - extra={ - "task_id": options.task_id, - "model_platform": options.model_platform, - }, - ) - - while True: - loop_iteration += 1 - logger.debug( - f"[LIFECYCLE] step_solve loop iteration #{loop_iteration}", - extra={ - "project_id": options.project_id, - "task_id": options.task_id, - }, - ) - - if await request.is_disconnected(): - logger.warning("=" * 80) - logger.warning( - "[LIFECYCLE] CLIENT DISCONNECTED " - f"for project {options.project_id}" - ) - logger.warning("=" * 80) - if workforce is not None: - logger.info( - "[LIFECYCLE] Stopping workforce " - "due to client disconnect, " - "workforce._running=" - f"{workforce._running}" - ) - if workforce._running: - workforce.stop() - workforce.stop_gracefully() - logger.info( - "[LIFECYCLE] Workforce stopped after client disconnect" - ) - else: - logger.info("[LIFECYCLE] Workforce is None, no need to stop") - task_lock.status = Status.done - try: - await delete_task_lock(task_lock.id) - logger.info( - "[LIFECYCLE] Task lock deleted after client disconnect" - ) - except Exception as e: - logger.error(f"Error deleting task lock on disconnect: {e}") - logger.info( - "[LIFECYCLE] Breaking out of " - "step_solve loop due to " - "client disconnect" - ) - break - try: - item = await task_lock.get_queue() - except Exception as e: - logger.error( - "Error getting item from queue", - extra={ - "project_id": options.project_id, - "task_id": options.task_id, - "error": str(e), - }, - exc_info=True, - ) - # Continue waiting instead of breaking on queue error - continue - - try: - if item.action == Action.improve or start_event_loop: - logger.info("=" * 80) - logger.info( - "[NEW-QUESTION] Action.improve " - "received or start_event_loop", - extra={ - "project_id": options.project_id, - "start_event_loop": start_event_loop, - }, - ) - wf_state = ( - "None" - if workforce is None - else f"exists(id={id(workforce)})" - ) - logger.info( - "[NEW-QUESTION] Current workforce" - f" state: workforce={wf_state}" - ) - ct_state = ( - "None" - if camel_task is None - else f"exists(id={camel_task.id})" - ) - logger.info( - "[NEW-QUESTION] Current " - "camel_task state: " - f"camel_task={ct_state}" - ) - logger.info("=" * 80) - # from viztracer import VizTracer - - # tracer = VizTracer() - # tracer.start() - if start_event_loop is True: - question = options.question - attaches_to_use = options.attaches - logger.info( - "[NEW-QUESTION] Initial question" - " from options.question: " - f"'{question[:100]}...'" - ) - start_event_loop = False - else: - assert isinstance(item, ActionImproveData) - question = item.data.question - attaches_to_use = ( - item.data.attaches - if item.data.attaches - else options.attaches - ) - logger.info( - "[NEW-QUESTION] Follow-up " - "question from " - "ActionImproveData: " - f"'{question[:100]}...'" - ) - - is_exceeded, total_length = check_conversation_history_length( - task_lock - ) - if is_exceeded: - logger.error( - "Conversation history too long", - extra={ - "project_id": options.project_id, - "current_length": total_length, - "max_length": 100000, - }, - ) - ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." - ) - yield sse_json( - "context_too_long", - { - "message": ctx_msg, - "current_length": total_length, - "max_length": 100000, - }, - ) - continue - - # Determine task complexity: attachments - # mean workforce, otherwise let agent decide - is_complex_task: bool - if len(attaches_to_use) > 0: - is_complex_task = True - logger.info( - "[NEW-QUESTION] Has attachments" - ", treating as complex task" - ) - else: - is_complex_task = await question_confirm( - question_agent, question, task_lock - ) - logger.info( - "[NEW-QUESTION] question_confirm" - " result: is_complex=" - f"{is_complex_task}" - ) - - if not is_complex_task: - logger.info( - "[NEW-QUESTION] Simple question" - ", providing direct answer " - "without workforce" - ) - conv_ctx = build_conversation_context( - task_lock, header="=== Previous Conversation ===" - ) - simple_answer_prompt = ( - f"{conv_ctx}" - f"User Query: {question}\n\n" - "Provide a direct, helpful " - "answer to this simple " - "question." - ) - - try: - simple_resp = question_agent.step(simple_answer_prompt) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[0].content - else: - answer_content = ( - "I understand your " - "question, but I'm " - "having trouble " - "generating a response " - "right now." - ) - - task_lock.add_conversation("assistant", answer_content) - - yield sse_json( - "wait_confirm", - {"content": answer_content, "question": question}, - ) - except Exception as e: - logger.error(f"Error generating simple answer: {e}") - yield sse_json( - "wait_confirm", - { - "content": "I encountered an error" - " while processing " - "your question.", - "question": question, - }, - ) - - # Clean up empty folder if it was created for this task - if ( - hasattr(task_lock, "new_folder_path") - and task_lock.new_folder_path - ): - try: - folder_path = Path(task_lock.new_folder_path) - if folder_path.exists() and folder_path.is_dir(): - # Check if folder is empty - if not any(folder_path.iterdir()): - folder_path.rmdir() - logger.info( - "Cleaned up empty" - " folder: " - f"{folder_path}" - ) - # Also clean up parent - # project folder if empty - project_folder = folder_path.parent - if project_folder.exists() and not any( - project_folder.iterdir() - ): - project_folder.rmdir() - logger.info( - "Cleaned up " - "empty project" - " folder: " - f"{project_folder}" - ) - else: - logger.info( - "Folder not empty" - ", keeping: " - f"{folder_path}" - ) - # Reset the folder path - task_lock.new_folder_path = None - except Exception as e: - logger.error(f"Error cleaning up folder: {e}") - else: - logger.info( - "[NEW-QUESTION] Complex task, " - "creating workforce and " - "decomposing" - ) - # Update the sync_step with new task_id - if hasattr(item, "new_task_id") and item.new_task_id: - set_current_task_id( - options.project_id, item.new_task_id - ) - task_lock.summary_generated = False - - yield sse_json("confirmed", {"question": question}) - - context_for_coordinator = build_context_for_workforce( - task_lock, options - ) - - # Check if workforce exists - reuse - # it; otherwise create new one - if workforce is not None: - logger.debug( - "[NEW-QUESTION] Reusing " - "existing workforce " - f"(id={id(workforce)})" - ) - else: - logger.info( - "[NEW-QUESTION] Creating NEW workforce instance" - ) - (workforce, mcp) = await construct_workforce(options) - for new_agent in options.new_agents: - workforce.add_single_agent_worker( - format_agent_description(new_agent), - await new_agent_model(new_agent, options), - ) - task_lock.status = Status.confirmed - - # Create camel_task for the question - clean_task_content = question + options.summary_prompt - camel_task = Task( - content=clean_task_content, id=options.task_id - ) - if len(attaches_to_use) > 0: - camel_task.additional_info = { - Path(file_path).name: file_path - for file_path in attaches_to_use - } - - # Stream decomposition in background - stream_state = { - "subtasks": [], - "seen_ids": set(), - "last_content": "", - } - state_holder: dict[str, Any] = { - "sub_tasks": [], - "summary_task": "", - } - - def on_stream_batch( - new_tasks: list[Task], is_final: bool = False - ): - fresh_tasks = [ - t - for t in new_tasks - if t.id not in stream_state["seen_ids"] - ] - for t in fresh_tasks: - stream_state["seen_ids"].add(t.id) - stream_state["subtasks"].extend(fresh_tasks) - - def on_stream_text(chunk): - try: - accumulated_content = ( - chunk.msg.content - if hasattr(chunk, "msg") and chunk.msg - else str(chunk) - ) - last_content = stream_state["last_content"] - - # Calculate delta: new content - # not in the previous chunk - if accumulated_content.startswith(last_content): - delta_content = accumulated_content[ - len(last_content) : - ] - else: - delta_content = accumulated_content - - stream_state["last_content"] = accumulated_content - - if delta_content: - asyncio.run_coroutine_threadsafe( - task_lock.put_queue( - ActionDecomposeTextData( - data={ - "project_id": options.project_id, - "task_id": options.task_id, - "content": delta_content, - } - ) - ), - event_loop, - ) - except Exception as e: - logger.warning( - f"Failed to stream decomposition text: {e}" - ) - - async def run_decomposition(): - nonlocal summary_task_content - try: - sub_tasks = await asyncio.to_thread( - workforce.eigent_make_sub_tasks, - camel_task, - context_for_coordinator, - on_stream_batch, - on_stream_text, - ) - - if stream_state["subtasks"]: - sub_tasks = stream_state["subtasks"] - state_holder["sub_tasks"] = sub_tasks - logger.info( - "Task decomposed into " - f"{len(sub_tasks)} subtasks" - ) - try: - task_lock.decompose_sub_tasks = sub_tasks - except Exception: - pass - - # Generate task summary - summary_task_agent = task_summary_agent(options) - try: - summary_task_content = await asyncio.wait_for( - summary_task( - summary_task_agent, camel_task - ), - timeout=10, - ) - task_lock.summary_generated = True - except TimeoutError: - logger.warning( - "summary_task timeout", - extra={ - "project_id": options.project_id, - "task_id": options.task_id, - }, - ) - task_lock.summary_generated = True - content_preview = ( - camel_task.content - if hasattr(camel_task, "content") - else "" - ) - if content_preview is None: - content_preview = "" - if len(content_preview) > 80: - cp = content_preview[:80] - summary_task_content = cp + "..." - else: - summary_task_content = content_preview - summary_task_content = ( - f"Task|{summary_task_content}" - ) - except Exception: - task_lock.summary_generated = True - content_preview = ( - camel_task.content - if hasattr(camel_task, "content") - else "" - ) - if content_preview is None: - content_preview = "" - if len(content_preview) > 80: - cp = content_preview[:80] - summary_task_content = cp + "..." - else: - summary_task_content = content_preview - summary_task_content = ( - f"Task|{summary_task_content}" - ) - - state_holder["summary_task"] = summary_task_content - try: - task_lock.summary_task_content = ( - summary_task_content - ) - except Exception: - pass - - payload = { - "project_id": options.project_id, - "task_id": options.task_id, - "sub_tasks": tree_sub_tasks( - camel_task.subtasks - ), - "delta_sub_tasks": tree_sub_tasks(sub_tasks), - "is_final": True, - "summary_task": summary_task_content, - } - await task_lock.put_queue( - ActionDecomposeProgressData(data=payload) - ) - except Exception as e: - logger.error( - f"Error in background decomposition: {e}", - exc_info=True, - ) - - bg_task = asyncio.create_task(run_decomposition()) - task_lock.add_background_task(bg_task) - - elif item.action == Action.update_task: - assert camel_task is not None - update_tasks = {item.id: item for item in item.data.task} - # Use stored decomposition results if available - if not sub_tasks: - sub_tasks = getattr(task_lock, "decompose_sub_tasks", []) - sub_tasks = update_sub_tasks(sub_tasks, update_tasks) - # Also update camel_task.subtasks - # to remove deleted tasks - # (used by to_sub_tasks) - update_sub_tasks(camel_task.subtasks, update_tasks) - # Add new tasks (with empty id) - # to both camel_task and sub_tasks - new_tasks = add_sub_tasks(camel_task, item.data.task) - # Also add new tasks to sub_tasks so - # workforce.eigent_start uses correct list - sub_tasks.extend(new_tasks) - # Save updated sub_tasks back to - # task_lock so Action.start uses - # the correct list - task_lock.decompose_sub_tasks = sub_tasks - summary_task_content_local = getattr( - task_lock, "summary_task_content", summary_task_content - ) - yield to_sub_tasks(camel_task, summary_task_content_local) - elif item.action == Action.add_task: - # Check if this might be a misrouted second question - if camel_task is None and workforce is None: - logger.error( - "Cannot add task: both " - "camel_task and workforce " - "are None for project " - f"{options.project_id}" - ) - yield sse_json( - "error", - { - "message": "Cannot add task: task not " - "initialized. Please start" - " a task first." - }, - ) - continue - - assert camel_task is not None - if workforce is None: - logger.error( - "Cannot add task: workforce" - " not initialized for " - "project " - f"{options.project_id}" - ) - yield sse_json( - "error", - { - "message": "Workforce not initialized." - " Please start the task " - "first." - }, - ) - continue - - # Add task to the workforce queue - workforce.add_task( - item.content, item.task_id, item.additional_info - ) - - returnData = { - "project_id": item.project_id, - "task_id": item.task_id or (len(camel_task.subtasks) + 1), - } - yield sse_json("add_task", returnData) - elif item.action == Action.remove_task: - if workforce is None: - logger.error( - "Cannot remove task: " - "workforce not initialized " - "for project " - f"{options.project_id}" - ) - yield sse_json( - "error", - { - "message": "Workforce not initialized." - " Please start the task " - "first." - }, - ) - continue - - workforce.remove_task(item.task_id) - returnData = { - "project_id": item.project_id, - "task_id": item.task_id, - } - yield sse_json("remove_task", returnData) - elif item.action == Action.skip_task: - logger.info("=" * 80) - logger.info( - "🛑 [LIFECYCLE] SKIP_TASK action " - "received (User clicked " - "Stop button)", - extra={ - "project_id": options.project_id, - "item_project_id": item.project_id, - }, - ) - logger.info("=" * 80) - - # Prevent duplicate skip processing - if task_lock.status == Status.done: - logger.warning( - "[LIFECYCLE] SKIP_TASK " - "received but task already " - "marked as done. Ignoring." - ) - continue - - wf_match = ( - workforce is not None - and item.project_id == options.project_id - ) - if wf_match: - logger.info( - "[LIFECYCLE] Workforce exists" - f" (id={id(workforce)}), " - "state=" - f"{workforce._state.name}, " - f"_running={workforce._running}" - ) - - # Stop workforce completely - logger.info("[LIFECYCLE] 🛑 Stopping workforce") - if workforce._running: - # Import correct BaseWorkforce from camel - from camel.societies.workforce.workforce import ( - Workforce as BaseWorkforce, - ) - - BaseWorkforce.stop(workforce) - logger.info( - "[LIFECYCLE] " - "BaseWorkforce.stop() " - "completed, state=" - f"{workforce._state.name}, " - f"_running={workforce._running}" - ) - - workforce.stop_gracefully() - logger.info("[LIFECYCLE] ✅ Workforce stopped gracefully") - - # Clear workforce to avoid state issues - # Next question will create fresh workforce - workforce = None - logger.info( - "[LIFECYCLE] Workforce set " - "to None, will be recreated" - " on next question" - ) - else: - logger.warning( - "[LIFECYCLE] Cannot skip: " - "workforce is None or " - "project_id mismatch" - ) - - # Mark task as done and preserve context (like Action.end does) - task_lock.status = Status.done - end_message = ( - "Task stoppedTask stopped by user" - ) - task_lock.last_task_result = end_message - - # Add to conversation history (like normal end does) - if camel_task is not None: - task_content: str = camel_task.content - if "=== CURRENT TASK ===" in task_content: - task_content = task_content.split( - "=== CURRENT TASK ===" - )[-1].strip() - else: - task_content: str = f"Task {options.task_id}" - - task_lock.add_conversation( - "task_result", - { - "task_content": task_content, - "task_result": end_message, - "working_directory": get_working_directory( - options, task_lock - ), - }, - ) - - # Clear camel_task as well - # (workforce is cleared, so - # camel_task should be too) - camel_task = None - logger.info( - "[LIFECYCLE] Task marked as " - "done, workforce and " - "camel_task cleared, " - "ready for multi-turn" - ) - - # Send end event to frontend with - # string format (matching normal - # end event format) - yield sse_json("end", end_message) - logger.info("[LIFECYCLE] Sent 'end' SSE event to frontend") - - # Continue loop to accept new - # questions (don't break, don't - # delete task_lock) - elif item.action == Action.start: - # Check conversation history length before starting task - is_exceeded, total_length = check_conversation_history_length( - task_lock - ) - if is_exceeded: - logger.error( - "Cannot start task: " - "conversation history too " - f"long ({total_length} chars)" - " for project " - f"{options.project_id}" - ) - ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." - ) - yield sse_json( - "context_too_long", - { - "message": ctx_msg, - "current_length": total_length, - "max_length": 100000, - }, - ) - continue - - if workforce is not None: - if workforce._state.name == "PAUSED": - # Resume paused workforce - - # subtasks should already - # be loaded - workforce.resume() - continue - else: - continue - - task_lock.status = Status.processing - if not sub_tasks: - sub_tasks = getattr(task_lock, "decompose_sub_tasks", []) - task = asyncio.create_task(workforce.eigent_start(sub_tasks)) - task_lock.add_background_task(task) - elif item.action == Action.task_state: - # Track completed task results for the end event - task_id = item.data.get("task_id", "unknown") - task_state = item.data.get("state", "unknown") - task_result = item.data.get("result", "") - - if task_state == "DONE" and task_result: - last_completed_task_result = task_result - - yield sse_json("task_state", item.data) - elif item.action == Action.new_task_state: - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] NEW_TASK_STATE action received (Multi-turn)", - extra={"project_id": options.project_id}, - ) - logger.info("=" * 80) - - # Log new task state details - new_task_id = item.data.get("task_id", "unknown") - new_task_state = item.data.get("state", "unknown") - new_task_result = item.data.get("result", "") - logger.info( - "[LIFECYCLE] New task details" - f": task_id={new_task_id}, " - f"state={new_task_state}" - ) - - if camel_task is None: - logger.error( - "NEW_TASK_STATE action " - "received but camel_task " - "is None for project " - f"{options.project_id}, " - f"task {new_task_id}" - ) - yield sse_json( - "error", - { - "message": "Cannot process new task " - "state: current task not " - "initialized." - }, - ) - continue - - old_task_content: str = camel_task.content - get_result = get_task_result_with_optional_summary - old_task_result: str = await get_result(camel_task, options) - - old_task_content_clean: str = old_task_content - if "=== CURRENT TASK ===" in old_task_content_clean: - old_task_content_clean = old_task_content_clean.split( - "=== CURRENT TASK ===" - )[-1].strip() - - task_lock.add_conversation( - "task_result", - { - "task_content": old_task_content_clean, - "task_result": old_task_result, - "working_directory": get_working_directory( - options, task_lock - ), - }, - ) - - new_task_content = item.data.get("content", "") - - if new_task_content: - import time - - task_id = item.data.get( - "task_id", f"{int(time.time() * 1000)}-multi" - ) - new_camel_task = Task(content=new_task_content, id=task_id) - if ( - hasattr(camel_task, "additional_info") - and camel_task.additional_info - ): - new_camel_task.additional_info = ( - camel_task.additional_info - ) - camel_task = new_camel_task - - # Now trigger end of previous task using stored result - yield sse_json("end", old_task_result) - - # Always yield new_task_state first - this is not optional - yield sse_json("new_task_state", item.data) - # Trigger Queue Removal - yield sse_json( - "remove_task", {"task_id": item.data.get("task_id")} - ) - - # Then handle multi-turn processing - if workforce is not None and new_task_content: - logger.info( - "[LIFECYCLE] Multi-turn: " - "workforce exists " - f"(id={id(workforce)}), " - "pausing for question " - "confirmation" - ) - task_lock.status = Status.confirming - workforce.pause() - logger.info( - "[LIFECYCLE] Multi-turn: " - "workforce paused, state=" - f"{workforce._state.name}" - ) - - try: - logger.info( - "[LIFECYCLE] Multi-turn: " - "calling question_confirm " - "for new task" - ) - is_multi_turn_complex = await question_confirm( - question_agent, new_task_content, task_lock - ) - logger.info( - "[LIFECYCLE] Multi-turn: " - "question_confirm result:" - " is_complex=" - f"{is_multi_turn_complex}" - ) - - if not is_multi_turn_complex: - logger.info( - "[LIFECYCLE] Multi-turn: " - "task is simple, providing" - " direct answer without " - "workforce" - ) - conv_ctx = build_conversation_context( - task_lock, - header="=== Previous Conversation ===", - ) - simple_answer_prompt = ( - f"{conv_ctx}" - "User Query: " - f"{new_task_content}" - "\n\nProvide a direct, " - "helpful answer to this " - "simple question." - ) - - try: - simple_resp = question_agent.step( - simple_answer_prompt - ) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[ - 0 - ].content - else: - answer_content = ( - "I understand your " - "question, but I'm " - "having trouble " - "generating a response" - " right now." - ) - - task_lock.add_conversation( - "assistant", answer_content - ) - - # Send response to user - # (don't send confirmed - # if simple response) - yield sse_json( - "wait_confirm", - { - "content": answer_content, - "question": new_task_content, - }, - ) - except Exception as e: - logger.error( - "Error generating simple " - f"answer in multi-turn: {e}" - ) - yield sse_json( - "wait_confirm", - { - "content": "I encountered an error " - "while processing your " - "question.", - "question": new_task_content, - }, - ) - - logger.info( - "[LIFECYCLE] Multi-turn: " - "simple answer provided, " - "resuming workforce" - ) - workforce.resume() - logger.info( - "[LIFECYCLE] Multi-turn: " - "workforce resumed, " - "continuing to next " - "iteration" - ) - # Continue the main while loop, - # waiting for next action - continue - - # Update the sync_step with new - # task_id before sending new - # task sse events - logger.info( - "[LIFECYCLE] Multi-turn: " - "task is complex, setting " - f"new task_id={task_id}" - ) - set_current_task_id(options.project_id, task_id) - - yield sse_json( - "confirmed", {"question": new_task_content} - ) - task_lock.status = Status.confirmed - - logger.info( - "[LIFECYCLE] Multi-turn: " - "building context for " - "workforce" - ) - context_for_multi_turn = build_context_for_workforce( - task_lock, options - ) - - stream_state = { - "subtasks": [], - "seen_ids": set(), - "last_content": "", - } - - def on_stream_batch( - new_tasks: list[Task], is_final: bool = False - ): - fresh_tasks = [ - t - for t in new_tasks - if t.id not in stream_state["seen_ids"] - ] - for t in fresh_tasks: - stream_state["seen_ids"].add(t.id) - stream_state["subtasks"].extend(fresh_tasks) - - def on_stream_text(chunk): - try: - has_msg = hasattr(chunk, "msg") and chunk.msg - accumulated_content = ( - chunk.msg.content - if has_msg - else str(chunk) - ) - last_content = stream_state["last_content"] - - if accumulated_content.startswith( - last_content - ): - delta_content = accumulated_content[ - len(last_content) : - ] - else: - delta_content = accumulated_content - - stream_state["last_content"] = ( - accumulated_content - ) - - if delta_content: - asyncio.run_coroutine_threadsafe( - task_lock.put_queue( - ActionDecomposeTextData( - data={ - "project_id": options.project_id, - "task_id": options.task_id, - "content": delta_content, - } - ) - ), - event_loop, - ) - except Exception as e: - logger.warning( - f"Failed to stream decomposition text: {e}" - ) - - wf = workforce - new_sub_tasks = await wf.handle_decompose_append_task( - camel_task, - reset=False, - coordinator_context=context_for_multi_turn, - on_stream_batch=on_stream_batch, - on_stream_text=on_stream_text, - ) - if stream_state["subtasks"]: - new_sub_tasks = stream_state["subtasks"] - n = len(new_sub_tasks) - logger.info( - "[LIFECYCLE] Multi-turn: " - "task decomposed into " - f"{n} subtasks" - ) - - # Generate proper LLM summary - # for multi-turn tasks instead - # of hardcoded fallback - try: - multi_turn_summary_agent = task_summary_agent( - options - ) - new_summary_content = await asyncio.wait_for( - summary_task( - multi_turn_summary_agent, camel_task - ), - timeout=10, - ) - logger.info( - "Generated LLM summary for multi-turn task", - extra={"project_id": options.project_id}, - ) - except TimeoutError: - logger.warning( - "Multi-turn summary_task timeout", - extra={ - "project_id": options.project_id, - "task_id": task_id, - }, - ) - # Fallback to descriptive but not generic summary - task_content_for_summary = new_task_content - tc = task_content_for_summary - if len(tc) > 100: - new_summary_content = ( - f"Follow-up Task|{tc[:97]}..." - ) - else: - new_summary_content = f"Follow-up Task|{tc}" - except Exception as e: - logger.error( - "Error generating multi-turn " - f"task summary: {e}" - ) - # Fallback to descriptive but not generic summary - task_content_for_summary = new_task_content - tc = task_content_for_summary - if len(tc) > 100: - new_summary_content = ( - f"Follow-up Task|{tc[:97]}..." - ) - else: - new_summary_content = f"Follow-up Task|{tc}" - - # Emit final subtasks once when - # decomposition is complete - final_payload = { - "project_id": options.project_id, - "task_id": options.task_id, - "sub_tasks": tree_sub_tasks(camel_task.subtasks), - "delta_sub_tasks": tree_sub_tasks(new_sub_tasks), - "is_final": True, - "summary_task": new_summary_content, - } - await task_lock.put_queue( - ActionDecomposeProgressData(data=final_payload) - ) - - # Update the context with new task data - sub_tasks = new_sub_tasks - summary_task_content = new_summary_content - - except Exception as e: - import traceback - - logger.error( - f"[TRACE] Traceback: {traceback.format_exc()}" - ) - # Continue with existing context if decomposition fails - yield sse_json( - "error", - {"message": f"Failed to process task: {str(e)}"}, - ) - else: - if workforce is None: - logger.warning( - "[TRACE] Workforce is None " - "- this might be the issue" - ) - if not new_task_content: - logger.warning("[TRACE] No new task content provided") - elif item.action == Action.create_agent: - yield sse_json("create_agent", item.data) - elif item.action == Action.activate_agent: - yield sse_json("activate_agent", item.data) - elif item.action == Action.deactivate_agent: - yield sse_json("deactivate_agent", dict(item.data)) - elif item.action == Action.assign_task: - yield sse_json("assign_task", item.data) - elif item.action == Action.activate_toolkit: - yield sse_json("activate_toolkit", item.data) - elif item.action == Action.deactivate_toolkit: - yield sse_json("deactivate_toolkit", item.data) - elif item.action == Action.write_file: - yield sse_json( - "write_file", - { - "file_path": item.data, - "process_task_id": item.process_task_id, - }, - ) - elif item.action == Action.ask: - yield sse_json("ask", item.data) - elif item.action == Action.notice: - yield sse_json( - "notice", - { - "notice": item.data, - "process_task_id": item.process_task_id, - }, - ) - elif item.action == Action.search_mcp: - yield sse_json("search_mcp", item.data) - elif item.action == Action.install_mcp: - if mcp is None: - logger.error( - "Cannot install MCP: mcp " - "agent not initialized for " - "project " - f"{options.project_id}" - ) - yield sse_json( - "error", - { - "message": "MCP agent not initialized." - " Please start a complex " - "task first." - }, - ) - continue - task = asyncio.create_task(install_mcp(mcp, item)) - task_lock.add_background_task(task) - elif item.action == Action.terminal: - yield sse_json( - "terminal", - { - "output": item.data, - "process_task_id": item.process_task_id, - }, - ) - elif item.action == Action.pause: - if workforce is not None: - workforce.pause() - logger.info( - f"Workforce paused for project {options.project_id}" - ) - else: - logger.warning( - "Cannot pause: workforce is " - "None for project " - f"{options.project_id}" - ) - elif item.action == Action.resume: - if workforce is not None: - workforce.resume() - logger.info( - f"Workforce resumed for project {options.project_id}" - ) - else: - logger.warning( - "Cannot resume: workforce " - "is None for project " - f"{options.project_id}" - ) - elif item.action == Action.decompose_text: - yield sse_json("decompose_text", item.data) - elif item.action == Action.decompose_progress: - yield sse_json("to_sub_tasks", item.data) - elif item.action == Action.new_agent: - if workforce is not None: - workforce.pause() - workforce.add_single_agent_worker( - format_agent_description(item), - await new_agent_model(item, options), - ) - workforce.resume() - elif item.action == Action.timeout: - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] TIMEOUT action " - "received for project " - f"{options.project_id}, " - f"task {options.task_id}" - ) - logger.info(f"[LIFECYCLE] Timeout data: {item.data}") - logger.info("=" * 80) - - # Send timeout error to frontend - timeout_message = item.data.get( - "message", "Task execution timeout" - ) - in_flight = item.data.get("in_flight_tasks", 0) - pending = item.data.get("pending_tasks", 0) - timeout_seconds = item.data.get("timeout_seconds", 0) - - yield sse_json( - "error", - { - "message": timeout_message, - "type": "timeout", - "details": { - "in_flight_tasks": in_flight, - "pending_tasks": pending, - "timeout_seconds": timeout_seconds, - }, - }, - ) - - elif item.action == Action.end: - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] END action " - "received for project " - f"{options.project_id}, " - f"task {options.task_id}" - ) - logger.info( - "[LIFECYCLE] camel_task " - f"exists: {camel_task is not None}" - ", current status: " - f"{task_lock.status}, workforce" - f" exists: {workforce is not None}" - ) - if workforce is not None: - logger.info( - "[LIFECYCLE] Workforce state" - " at END: _state=" - f"{workforce._state.name}" - ", _running=" - f"{workforce._running}" - ) - logger.info("=" * 80) - - # Prevent duplicate end processing - if task_lock.status == Status.done: - logger.warning( - "[LIFECYCLE] END action " - "received but task already " - "marked as done. Ignoring " - "duplicate END action." - ) - continue - - if camel_task is None: - logger.warning( - "END action received but " - "camel_task is None for " - "project " - f"{options.project_id}, " - f"task {options.task_id}. " - "This may indicate multiple " - "END actions or improper " - "task lifecycle management." - ) - # Use item data as final result - # if camel_task is None - final_result: str = ( - str(item.data) if item.data else "Task completed" - ) - else: - get_result = get_task_result_with_optional_summary - final_result: str = await get_result(camel_task, options) - - task_lock.status = Status.done - - task_lock.last_task_result = final_result - - # Handle task content - use fallback if camel_task is None - if camel_task is not None: - task_content: str = camel_task.content - if "=== CURRENT TASK ===" in task_content: - task_content = task_content.split( - "=== CURRENT TASK ===" - )[-1].strip() - else: - task_content: str = f"Task {options.task_id}" - - task_lock.add_conversation( - "task_result", - { - "task_content": task_content, - "task_result": final_result, - "working_directory": get_working_directory( - options, task_lock - ), - }, - ) - - yield sse_json("end", final_result) - - if workforce is not None: - logger.info( - "[LIFECYCLE] Calling " - "workforce.stop_gracefully()" - " for project " - f"{options.project_id}, " - f"workforce id={id(workforce)}" - ) - workforce.stop_gracefully() - logger.info( - "[LIFECYCLE] Workforce " - "stopped gracefully for " - "project " - f"{options.project_id}" - ) - workforce = None - logger.info("[LIFECYCLE] Workforce set to None") - else: - logger.warning( - "[LIFECYCLE] Workforce " - "already None at end " - "action for project " - f"{options.project_id}" - ) - - camel_task = None - logger.info("[LIFECYCLE] camel_task set to None") - - if question_agent is not None: - question_agent.reset() - logger.info( - "[LIFECYCLE] question_agent" - " reset for project " - f"{options.project_id}" - ) - elif item.action == Action.supplement: - # Check if this might be a misrouted second question - if camel_task is None: - logger.warning( - "SUPPLEMENT action received " - "but camel_task is None for " - f"project {options.project_id}" - ) - yield sse_json( - "error", - { - "message": "Cannot supplement task: " - "task not initialized. " - "Please start a task " - "first." - }, - ) - continue - else: - task_lock.status = Status.processing - camel_task.add_subtask( - Task( - content=item.data.question, - id=f"{camel_task.id}.{len(camel_task.subtasks)}", - ) - ) - if workforce is not None: - task = asyncio.create_task( - workforce.eigent_start(camel_task.subtasks) - ) - task_lock.add_background_task(task) - elif item.action == Action.budget_not_enough: - if workforce is not None: - workforce.pause() - yield sse_json( - Action.budget_not_enough, {"message": "budget not enouth"} - ) - elif item.action == Action.stop: - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] STOP action received" - " for project " - f"{options.project_id}" - ) - logger.info("=" * 80) - if workforce is not None: - logger.info( - "[LIFECYCLE] Workforce exists " - f"(id={id(workforce)}), " - f"_running={workforce._running}" - ", _state=" - f"{workforce._state.name}" - ) - if workforce._running: - logger.info( - "[LIFECYCLE] Calling " - "workforce.stop() because" - " _running=True" - ) - workforce.stop() - logger.info("[LIFECYCLE] workforce.stop() completed") - logger.info( - "[LIFECYCLE] Calling workforce.stop_gracefully()" - ) - workforce.stop_gracefully() - logger.info( - "[LIFECYCLE] Workforce stopped" - " for project " - f"{options.project_id}" - ) - else: - logger.warning( - "[LIFECYCLE] Workforce is None" - " at stop action for project" - f" {options.project_id}" - ) - logger.info("[LIFECYCLE] Deleting task lock") - await delete_task_lock(task_lock.id) - logger.info( - "[LIFECYCLE] Task lock deleted, breaking out of loop" - ) - break - else: - logger.warning(f"Unknown action: {item.action}") - except ModelProcessingError as e: - if "Budget has been exceeded" in str(e): - logger.warning( - "Budget exceeded for task " - f"{options.task_id}, action: " - f"{item.action}" - ) - # workforce decompose task don't use - # ListenAgent, this need return sse - if "workforce" in locals() and workforce is not None: - workforce.pause() - yield sse_json( - Action.budget_not_enough, {"message": "budget not enouth"} - ) - else: - logger.error( - "ModelProcessingError for task " - f"{options.task_id}, action " - f"{item.action}: {e}", - exc_info=True, - ) - yield sse_json("error", {"message": str(e)}) - if ( - "workforce" in locals() - and workforce is not None - and workforce._running - ): - workforce.stop() - except Exception as e: - logger.error( - "Unhandled exception for task " - f"{options.task_id}, action " - f"{item.action}: {e}", - exc_info=True, - ) - yield sse_json("error", {"message": str(e)}) - # Continue processing other items instead of breaking - - -async def install_mcp( - mcp: ListenChatAgent, - install_mcp: ActionInstallMcpData, -): - mcp_keys = list(install_mcp.data.get("mcpServers", {}).keys()) - logger.info(f"Installing MCP tools: {mcp_keys}") - try: - mcp.add_tools(await get_mcp_tools(install_mcp.data)) - logger.info("MCP tools installed successfully") - except Exception as e: - logger.error(f"Error installing MCP tools: {e}", exc_info=True) - raise - - -def to_sub_tasks(task: Task, summary_task_content: str): - logger.info("[TO-SUB-TASKS] 📋 Creating to_sub_tasks SSE event") - logger.info( - f"[TO-SUB-TASKS] task.id={task.id}" - f", summary={summary_task_content[:50]}" - f"..., subtasks_count=" - f"{len(task.subtasks)}" - ) - result = sse_json( - "to_sub_tasks", - { - "summary_task": summary_task_content, - "sub_tasks": tree_sub_tasks(task.subtasks), - }, - ) - logger.info("[TO-SUB-TASKS] ✅ to_sub_tasks SSE event created") - return result - - -def tree_sub_tasks(sub_tasks: list[Task], depth: int = 0): - if depth > 5: - return [] - - result = ( - chain(sub_tasks) - .filter(lambda x: x.content != "") - .map( - lambda x: { - "id": x.id, - "content": x.content, - "state": x.state, - "subtasks": tree_sub_tasks(x.subtasks, depth + 1), - } - ) - .value() - ) - - return result - - -def update_sub_tasks( - sub_tasks: list[Task], update_tasks: dict[str, TaskContent], depth: int = 0 -): - if depth > 5: # limit the depth of the recursion - return [] - - i = 0 - while i < len(sub_tasks): - item = sub_tasks[i] - if item.id in update_tasks: - item.content = update_tasks[item.id].content - update_sub_tasks(item.subtasks, update_tasks, depth + 1) - i += 1 - else: - sub_tasks.pop(i) - return sub_tasks - - -def add_sub_tasks( - camel_task: Task, update_tasks: list[TaskContent] -) -> list[Task]: - """Add new tasks (with empty id) to camel_task - and return the list of added tasks.""" - added_tasks = [] - for item in update_tasks: - if item.id == "": - new_task = Task( - content=item.content, - id=f"{camel_task.id}.{len(camel_task.subtasks) + 1}", - ) - camel_task.add_subtask(new_task) - added_tasks.append(new_task) - return added_tasks - - -async def question_confirm( - agent: ListenChatAgent, prompt: str, task_lock: TaskLock | None = None -) -> bool: - """Simple question confirmation - returns True - for complex tasks, False for simple questions.""" - - context_prompt = "" - if task_lock: - context_prompt = build_conversation_context( - task_lock, header="=== Previous Conversation ===" - ) - - full_prompt = f"""{context_prompt}User Query: {prompt} - -Determine if this user query is a complex task or a simple question. - -**Complex task** (answer "yes"): Requires tools, code execution, \ -file operations, multi-step planning, or creating/modifying content -- Examples: "create a file", "search for X", \ -"implement feature Y", "write code", "analyze data" - -**Simple question** (answer "no"): Can be answered directly \ -with knowledge or conversation history, no action needed -- Examples: greetings ("hello", "hi"), \ -fact queries ("what is X?"), clarifications, status checks - -Answer only "yes" or "no". Do not provide any explanation. - -Is this a complex task? (yes/no):""" - - try: - resp = agent.step(full_prompt) - - if not resp or not resp.msgs or len(resp.msgs) == 0: - logger.warning( - "No response from agent, defaulting to complex task" - ) - return True - - content = resp.msgs[0].content - if not content: - logger.warning( - "Empty content from agent, defaulting to complex task" - ) - return True - - normalized = content.strip().lower() - is_complex = "yes" in normalized - - result_str = "complex task" if is_complex else "simple question" - logger.info( - f"Question confirm result: {result_str}", - extra={"response": content, "is_complex": is_complex}, - ) - - return is_complex - - except Exception as e: - logger.error(f"Error in question_confirm: {e}") - raise - - -async def summary_task(agent: ListenChatAgent, task: Task) -> str: - prompt = f"""The user's task is: ---- -{task.to_string()} ---- -Your instructions are: -1. Come up with a short and descriptive name for this task. -2. Create a concise summary of the task's main points and objectives. -3. Return the task name and the summary, separated by a vertical bar (|). - -Example format: "Task Name|This is the summary of the task." -Do not include any other text or formatting. -""" - logger.debug("Generating task summary", extra={"task_id": task.id}) - try: - res = agent.step(prompt) - summary = res.msgs[0].content - logger.info("Task summary generated", extra={"summary": summary}) - return summary - except Exception as e: - logger.error( - "Error generating task summary", - extra={"error": str(e)}, - exc_info=True, - ) - raise - - -async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: - """ - Summarize the aggregated results from all subtasks into a concise summary. - - Args: - agent: The summary agent to use - task: The main task containing subtasks and their aggregated results - - Returns: - A concise summary of all subtask results - """ - subtasks_info = "" - for i, subtask in enumerate(task.subtasks, 1): - subtasks_info += f"\n**Subtask {i}**\n" - subtasks_info += f"Description: {subtask.content}\n" - subtasks_info += f"Result: {subtask.result or 'No result'}\n" - subtasks_info += "---\n" - - prompt = f"""You are a professional summarizer. \ -Summarize the results of the following subtasks. - -Main Task: {task.content} - -Subtasks (with descriptions and results): ---- -{subtasks_info} ---- - -Instructions: -1. Provide a concise summary of what was accomplished -2. Highlight key findings or outputs from each subtask -3. Mention any important files created or actions taken -4. Use bullet points or sections for clarity -5. DO NOT repeat the task name in your summary - go straight to the results -6. Keep it professional but conversational - -Summary: -""" - - res = agent.step(prompt) - summary = res.msgs[0].content - - logger.info( - "Generated subtasks summary for " - f"task {task.id} with " - f"{len(task.subtasks)} subtasks" - ) - - return summary - - -async def get_task_result_with_optional_summary( - task: Task, options: Chat -) -> str: - """ - Get the task result, with LLM summary if there are multiple subtasks. - - Args: - task: The task to get result from - options: Chat options for creating summary agent - - Returns: - The task result (summarized if multiple subtasks, raw otherwise) - """ - result = str(task.result or "") - - if task.subtasks and len(task.subtasks) > 1: - logger.info( - f"Task {task.id} has " - f"{len(task.subtasks)} subtasks, " - "generating summary" - ) - try: - summary_agent = task_summary_agent(options) - summarized_result = await summary_subtasks_result( - summary_agent, task - ) - result = summarized_result - logger.info(f"Successfully generated summary for task {task.id}") - except Exception as e: - logger.error(f"Failed to generate summary for task {task.id}: {e}") - elif task.subtasks and len(task.subtasks) == 1: - logger.info(f"Task {task.id} has only 1 subtask, skipping LLM summary") - if result and "--- Subtask" in result and "Result ---" in result: - parts = result.split("Result ---", 1) - if len(parts) > 1: - result = parts[1].strip() - - return result - - -async def construct_workforce( - options: Chat, -) -> tuple[Workforce, ListenChatAgent]: - """Construct a workforce with all required agents. - - This function creates all agents in PARALLEL to minimize startup time. - Sync functions are run in thread pool, async functions - are awaited concurrently. - """ - logger.debug( - "construct_workforce started", - extra={"project_id": options.project_id, "task_id": options.task_id}, - ) - - # Store main event loop reference for thread-safe async task scheduling - # This allows agent_model() to schedule tasks - # when called from worker threads - set_main_event_loop(asyncio.get_running_loop()) - - working_directory = get_working_directory(options) - - # ======================================================================== - # Define agent creation functions - # ======================================================================== - - def _create_coordinator_and_task_agents() -> list[ListenChatAgent]: - """Create coordinator and task agents (sync, runs in thread pool).""" - return [ - agent_model( - key, - prompt, - options, - [ - *( - ToolkitMessageIntegration( - message_handler=HumanToolkit( - options.project_id, key - ).send_message_to_user - ).register_toolkits( - NoteTakingToolkit( - options.project_id, - working_directory=working_directory, - ) - ) - ).get_tools(), - *SkillToolkit( - options.project_id, - key, - working_directory=working_directory, - user_id=options.skill_config_user_id(), - ).get_tools(), - ], - ) - for key, prompt in { - Agents.coordinator_agent: f""" -You are a helpful coordinator. -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. - """, - Agents.task_agent: f""" -You are a helpful task planner. -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. - """, - }.items() - ] - - def _create_new_worker_agent() -> ListenChatAgent: - """Create new worker agent (sync, runs in thread pool).""" - return agent_model( - Agents.new_worker_agent, - f""" - You are a helpful assistant. -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. - """, - options, - [ - *HumanToolkit.get_can_use_tools( - options.project_id, Agents.new_worker_agent - ), - *( - ToolkitMessageIntegration( - message_handler=HumanToolkit( - options.project_id, Agents.new_worker_agent - ).send_message_to_user - ).register_toolkits( - NoteTakingToolkit( - options.project_id, - working_directory=working_directory, - ) - ) - ).get_tools(), - *SkillToolkit( - options.project_id, - Agents.new_worker_agent, - working_directory=working_directory, - user_id=options.skill_config_user_id(), - ).get_tools(), - ], - ) - - # ======================================================================== - # Execute all agent creations in PARALLEL - # ======================================================================== - - try: - # asyncio.gather runs all coroutines concurrently - # asyncio.to_thread runs sync functions in - # thread pool without blocking event loop - results = await asyncio.gather( - asyncio.to_thread(_create_coordinator_and_task_agents), - asyncio.to_thread(_create_new_worker_agent), - asyncio.to_thread(browser_agent, options), - developer_agent(options), - document_agent(options), - asyncio.to_thread(multi_modal_agent, options), - mcp_agent(options), - ) - except Exception as e: - logger.error( - f"Failed to create agents in parallel: {e}", exc_info=True - ) - raise - finally: - # Always clear event loop reference after - # parallel agent creation completes. - # This prevents stale references and - # potential cross-request interference - set_main_event_loop(None) - - # Unpack results - ( - coord_task_agents, - new_worker_agent, - searcher, - developer, - documenter, - multi_modaler, - mcp, - ) = results - - coordinator_agent, task_agent = coord_task_agents - - # ======================================================================== - # Create Workforce instance and add workers (must be sequential) - # ======================================================================== - - try: - model_platform_enum = ModelPlatformType(options.model_platform.lower()) - except (ValueError, AttributeError): - model_platform_enum = None - - # Create workforce metrics callback for workforce analytics - workforce_metrics = WorkforceMetricsCallback( - project_id=options.project_id, task_id=options.task_id - ) - - workforce = Workforce( - options.project_id, - "A workforce", - graceful_shutdown_timeout=3, - share_memory=False, - coordinator_agent=coordinator_agent, - task_agent=task_agent, - new_worker_agent=new_worker_agent, - use_structured_output_handler=False - if model_platform_enum == ModelPlatformType.OPENAI - else True, - ) - - # Register workforce metrics callback - workforce._callbacks.append(workforce_metrics) - workforce.add_single_agent_worker( - "Developer Agent: A master-level coding assistant with a powerful " - "terminal. It can write and execute code, manage files, automate " - "desktop tasks, and deploy web applications to solve complex " - "technical challenges.", - developer, - ) - workforce.add_single_agent_worker( - "Browser Agent: Can search the web, extract webpage content, " - "simulate browser actions, and provide relevant information to " - "solve the given task.", - searcher, - ) - workforce.add_single_agent_worker( - "Document Agent: A document processing assistant skilled in creating " - "and modifying a wide range of file formats. It can generate " - "text-based files/reports (Markdown, JSON, YAML, HTML), " - "office documents (Word, PDF), presentations (PowerPoint), and " - "data files (Excel, CSV).", - documenter, - ) - workforce.add_single_agent_worker( - "Multi-Modal Agent: A specialist in media processing. It can " - "analyze images and audio, transcribe speech, download videos, and " - "generate new images from text prompts.", - multi_modaler, - ) - - return workforce, mcp - - -def format_agent_description(agent_data: NewAgent | ActionNewAgent) -> str: - r"""Format a comprehensive agent description including name, tools, and - description. - """ - description_parts = [f"{agent_data.name}:"] - - # Add description if available - if hasattr(agent_data, "description") and agent_data.description: - description_parts.append(agent_data.description.strip()) - else: - description_parts.append("A specialized agent") - - # Add tools information - tool_names = [] - if hasattr(agent_data, "tools") and agent_data.tools: - for tool in agent_data.tools: - tool_names.append(titleize(tool)) - - if hasattr(agent_data, "mcp_tools") and agent_data.mcp_tools: - for mcp_server in agent_data.mcp_tools.get("mcpServers", {}).keys(): - tool_names.append(titleize(mcp_server)) - - if tool_names: - description_parts.append( - f"with access to {', '.join(tool_names)} tools : <{tool_names}>" - ) - - return " ".join(description_parts) - - -async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): - logger.info( - "Creating new agent", - extra={ - "agent_name": data.name, - "project_id": options.project_id, - "task_id": options.task_id, - }, - ) - logger.debug( - "New agent data", extra={"agent_data": data.model_dump_json()} - ) - working_directory = get_working_directory(options) - tool_names = [] - tools = [*await get_toolkits(data.tools, data.name, options.project_id)] - for item in data.tools: - tool_names.append(titleize(item)) - # Always include terminal_toolkit with proper working directory - terminal_toolkit = TerminalToolkit( - options.project_id, - agent_name=data.name, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - tools.extend(terminal_toolkit.get_tools()) - tool_names.append(titleize("terminal_toolkit")) - if data.mcp_tools is not None: - tools = [*tools, *await get_mcp_tools(data.mcp_tools)] - for item in data.mcp_tools["mcpServers"].keys(): - tool_names.append(titleize(item)) - for item in tools: - logger.debug(f"Agent {data.name} tool: {item.func.__name__}") - logger.info( - f"Agent {data.name} created with {len(tools)} tools: {tool_names}" - ) - # Enhanced system message with platform information - enhanced_description = f"""{data.description} -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. -""" - - # Pass per-agent custom model config if available - custom_model_config = getattr(data, "custom_model_config", None) - return agent_model( - data.name, - enhanced_description, - options, - tools, - tool_names=tool_names, - custom_model_config=custom_model_config, - ) diff --git a/backend/app/service/chat_service/__init__.py b/backend/app/service/chat_service/__init__.py new file mode 100644 index 000000000..6c59039a7 --- /dev/null +++ b/backend/app/service/chat_service/__init__.py @@ -0,0 +1,61 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Chat service package — re-exports for backward compatibility.""" + +from app.agent.factory.question_confirm import question_confirm +from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + summary_subtasks_result, + summary_task, +) +from app.service.chat_service._step_solve import step_solve +from app.service.chat_service.lifecycle import ( + add_sub_tasks, + construct_workforce, + format_agent_description, + install_mcp, + new_agent_model, + to_sub_tasks, + tree_sub_tasks, + update_sub_tasks, +) +from app.utils.context import ( + build_context_for_workforce, + build_conversation_context, + check_conversation_history_length, + collect_previous_task_context, + format_task_context, +) + +__all__ = [ + "step_solve", + "build_context_for_workforce", + "build_conversation_context", + "check_conversation_history_length", + "collect_previous_task_context", + "format_task_context", + "get_task_result_with_optional_summary", + "question_confirm", + "summary_subtasks_result", + "summary_task", + "add_sub_tasks", + "construct_workforce", + "format_agent_description", + "install_mcp", + "new_agent_model", + "to_sub_tasks", + "tree_sub_tasks", + "update_sub_tasks", +] diff --git a/backend/app/service/chat_service/_step_solve.py b/backend/app/service/chat_service/_step_solve.py new file mode 100644 index 000000000..3d58b341e --- /dev/null +++ b/backend/app/service/chat_service/_step_solve.py @@ -0,0 +1,293 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Main step_solve dispatcher and shared state.""" + +import asyncio +import logging +from dataclasses import dataclass, field +from enum import Enum + +from camel.models import ModelProcessingError +from camel.tasks import Task +from fastapi import Request + +from app.agent.factory import question_confirm_agent +from app.agent.listen_chat_agent import ListenChatAgent +from app.model.chat import Chat, sse_json +from app.service.chat_service.context import handle_passthrough_event +from app.service.chat_service.lifecycle import ( + handle_add_task, + handle_budget_not_enough, + handle_disconnect, + handle_disconnect_cleanup, + handle_end, + handle_install_mcp, + handle_new_agent, + handle_pause, + handle_remove_task, + handle_resume, + handle_skip_task, + handle_start, + handle_stop, + handle_supplement, + handle_task_state, + handle_timeout, + handle_update_task, +) +from app.service.chat_service.router import ( + handle_improve, + handle_new_task_state, +) +from app.service.task import Action, TaskLock +from app.utils.server.sync_step import sync_step +from app.utils.workforce import Workforce + +logger = logging.getLogger("chat_service") + + +class LoopControl(Enum): + """Control flow for the main step_solve loop.""" + + NORMAL = "normal" + CONTINUE = "continue" + BREAK = "break" + + +@dataclass +class StepSolveState: + """Mutable state bag for step_solve's main loop.""" + + options: Chat + request: Request + task_lock: TaskLock + workforce: Workforce | None = None + camel_task: Task | None = None + mcp: ListenChatAgent | None = None + question_agent: ListenChatAgent | None = None + sub_tasks: list[Task] = field(default_factory=list) + summary_task_content: str = "" + last_completed_task_result: str = "" + start_event_loop: bool = True + event_loop: asyncio.AbstractEventLoop | None = None + + +def _initialize_state(state: StepSolveState) -> None: + """Initialize task_lock attributes and question_agent.""" + if not hasattr(state.task_lock, "conversation_history"): + state.task_lock.conversation_history = [] + if not hasattr(state.task_lock, "last_task_result"): + state.task_lock.last_task_result = "" + if not hasattr(state.task_lock, "question_agent"): + state.task_lock.question_agent = None + if not hasattr(state.task_lock, "summary_generated"): + state.task_lock.summary_generated = False + + # Create or reuse persistent question_agent + if state.task_lock.question_agent is None: + state.task_lock.question_agent = question_confirm_agent(state.options) + else: + hist_len = len(state.task_lock.conversation_history) + logger.debug( + f"Reusing existing question_agent with {hist_len} history entries" + ) + + state.question_agent = state.task_lock.question_agent + state.event_loop = asyncio.get_running_loop() + + +@sync_step +async def step_solve(options: Chat, request: Request, task_lock: TaskLock): + """Main task execution loop. Called when POST /chat endpoint + is hit to start a new chat session. + + Processes task queue, manages workforce lifecycle, and streams + responses back to the client via SSE. + + Args: + options (Chat): Chat configuration containing task details and + model settings. + request (Request): FastAPI request object for client connection + management. + task_lock (TaskLock): Shared task state and queue for the project. + + Yields: + SSE formatted responses for task progress, errors, and results + """ + state = StepSolveState( + options=options, + request=request, + task_lock=task_lock, + ) + _initialize_state(state) + + loop_iteration = 0 + + logger.info("=" * 80) + logger.info( + "🚀 [LIFECYCLE] step_solve STARTED", + extra={"project_id": options.project_id, "task_id": options.task_id}, + ) + logger.info("=" * 80) + logger.debug( + "Step solve options", + extra={ + "task_id": options.task_id, + "model_platform": options.model_platform, + }, + ) + + while True: + loop_iteration += 1 + logger.debug( + f"[LIFECYCLE] step_solve loop iteration #{loop_iteration}", + extra={ + "project_id": options.project_id, + "task_id": options.task_id, + }, + ) + + # Check for client disconnect + if await request.is_disconnected(): + events, control = handle_disconnect(state) + for event in events: + yield event + await handle_disconnect_cleanup(state) + logger.info( + "[LIFECYCLE] Breaking out of " + "step_solve loop due to " + "client disconnect" + ) + break + + try: + item = await task_lock.get_queue() + except Exception as e: + logger.error( + "Error getting item from queue", + extra={ + "project_id": options.project_id, + "task_id": options.task_id, + "error": str(e), + }, + exc_info=True, + ) + # Continue waiting instead of breaking on queue error + continue + + try: + events: list[str] = [] + control = LoopControl.NORMAL + + if item.action == Action.improve or state.start_event_loop: + events, control = await handle_improve(state, item) + + elif item.action == Action.update_task: + events, control = handle_update_task(state, item) + + elif item.action == Action.add_task: + events, control = handle_add_task(state, item) + + elif item.action == Action.remove_task: + events, control = handle_remove_task(state, item) + + elif item.action == Action.skip_task: + events, control = handle_skip_task(state, item) + + elif item.action == Action.start: + events, control = handle_start(state, item) + + elif item.action == Action.task_state: + events, control = handle_task_state(state, item) + + elif item.action == Action.new_task_state: + events, control = await handle_new_task_state(state, item) + + elif item.action == Action.end: + events, control = await handle_end(state, item) + + elif item.action == Action.supplement: + events, control = handle_supplement(state, item) + + elif item.action == Action.budget_not_enough: + events, control = handle_budget_not_enough(state, item) + + elif item.action == Action.stop: + events, control = await handle_stop(state, item) + + elif item.action == Action.pause: + events, control = handle_pause(state, item) + + elif item.action == Action.resume: + events, control = handle_resume(state, item) + + elif item.action == Action.new_agent: + events, control = await handle_new_agent(state, item) + + elif item.action == Action.timeout: + events, control = handle_timeout(state, item) + + elif item.action == Action.install_mcp: + events, control = handle_install_mcp(state, item) + + else: + # Try passthrough events + passthrough = handle_passthrough_event(item) + if passthrough is not None: + events = [passthrough] + else: + logger.warning(f"Unknown action: {item.action}") + + # Yield all events + for event in events: + yield event + + # Handle loop control + if control == LoopControl.BREAK: + break + elif control == LoopControl.CONTINUE: + continue + + except ModelProcessingError as e: + if "Budget has been exceeded" in str(e): + logger.warning( + "Budget exceeded for task " + f"{options.task_id}, action: " + f"{item.action}" + ) + if state.workforce is not None: + state.workforce.pause() + yield sse_json( + Action.budget_not_enough, + {"message": "budget not enouth"}, + ) + else: + logger.error( + "ModelProcessingError for task " + f"{options.task_id}, action " + f"{item.action}: {e}", + exc_info=True, + ) + yield sse_json("error", {"message": str(e)}) + if state.workforce is not None and state.workforce._running: + state.workforce.stop() + except Exception as e: + logger.error( + "Unhandled exception for task " + f"{options.task_id}, action " + f"{item.action}: {e}", + exc_info=True, + ) + yield sse_json("error", {"message": str(e)}) + # Continue processing other items instead of breaking diff --git a/backend/app/service/chat_service/context.py b/backend/app/service/chat_service/context.py new file mode 100644 index 000000000..8a99ac079 --- /dev/null +++ b/backend/app/service/chat_service/context.py @@ -0,0 +1,80 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Passthrough SSE event handling + re-exports of shared context utilities.""" + +from app.model.chat import sse_json +from app.service.task import Action + +# Re-export context utilities from shared location +from app.utils.context import ( # noqa: F401 + build_context_for_workforce, + build_conversation_context, + check_conversation_history_length, + collect_previous_task_context, + format_task_context, +) + + +def handle_passthrough_event(item) -> str | None: + """Handle simple passthrough events that just forward data as SSE. + + Returns: + SSE JSON string if the action is a passthrough, None otherwise. + """ + if item.action == Action.create_agent: + return sse_json("create_agent", item.data) + elif item.action == Action.activate_agent: + return sse_json("activate_agent", item.data) + elif item.action == Action.deactivate_agent: + return sse_json("deactivate_agent", dict(item.data)) + elif item.action == Action.assign_task: + return sse_json("assign_task", item.data) + elif item.action == Action.activate_toolkit: + return sse_json("activate_toolkit", item.data) + elif item.action == Action.deactivate_toolkit: + return sse_json("deactivate_toolkit", item.data) + elif item.action == Action.write_file: + return sse_json( + "write_file", + { + "file_path": item.data, + "process_task_id": item.process_task_id, + }, + ) + elif item.action == Action.ask: + return sse_json("ask", item.data) + elif item.action == Action.notice: + return sse_json( + "notice", + { + "notice": item.data, + "process_task_id": item.process_task_id, + }, + ) + elif item.action == Action.search_mcp: + return sse_json("search_mcp", item.data) + elif item.action == Action.terminal: + return sse_json( + "terminal", + { + "output": item.data, + "process_task_id": item.process_task_id, + }, + ) + elif item.action == Action.decompose_text: + return sse_json("decompose_text", item.data) + elif item.action == Action.decompose_progress: + return sse_json("to_sub_tasks", item.data) + return None diff --git a/backend/app/service/chat_service/decomposition.py b/backend/app/service/chat_service/decomposition.py new file mode 100644 index 000000000..a42d8a882 --- /dev/null +++ b/backend/app/service/chat_service/decomposition.py @@ -0,0 +1,272 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Task decomposition and streaming callbacks.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from camel.tasks import Task + +from app.agent.factory.task_summary import summary_task, task_summary_agent +from app.model.chat import Status, sse_json +from app.service.chat_service.lifecycle import ( + construct_workforce, + format_agent_description, + new_agent_model, + tree_sub_tasks, +) +from app.service.task import ( + ActionDecomposeProgressData, + ActionDecomposeTextData, + set_current_task_id, +) +from app.utils.context import build_context_for_workforce + +if TYPE_CHECKING: + from app.service.chat_service._step_solve import ( + LoopControl, + StepSolveState, + ) + +logger = logging.getLogger("chat_service") + + +def create_stream_callbacks( + state: StepSolveState, +) -> tuple[Any, Any, dict]: + """Create streaming callbacks for decomposition. + + Returns: + (on_stream_batch, on_stream_text, stream_state) tuple. + """ + stream_state = { + "subtasks": [], + "seen_ids": set(), + "last_content": "", + } + + def on_stream_batch(new_tasks: list[Task], is_final: bool = False): + fresh_tasks = [ + t for t in new_tasks if t.id not in stream_state["seen_ids"] + ] + for t in fresh_tasks: + stream_state["seen_ids"].add(t.id) + stream_state["subtasks"].extend(fresh_tasks) + + def on_stream_text(chunk): + try: + accumulated_content = ( + chunk.msg.content + if hasattr(chunk, "msg") and chunk.msg + else str(chunk) + ) + last_content = stream_state["last_content"] + + # Calculate delta: new content not in the previous chunk + if accumulated_content.startswith(last_content): + delta_content = accumulated_content[len(last_content) :] + else: + delta_content = accumulated_content + + stream_state["last_content"] = accumulated_content + + if delta_content: + asyncio.run_coroutine_threadsafe( + state.task_lock.put_queue( + ActionDecomposeTextData( + data={ + "project_id": state.options.project_id, + "task_id": state.options.task_id, + "content": delta_content, + } + ) + ), + state.event_loop, + ) + except Exception as e: + logger.warning(f"Failed to stream decomposition text: {e}") + + return on_stream_batch, on_stream_text, stream_state + + +async def run_decomposition_task( + state: StepSolveState, + context_for_coordinator: str, + on_stream_batch, + on_stream_text, + stream_state: dict, +) -> None: + """Run task decomposition in background, generating summary and + streaming progress.""" + try: + sub_tasks = await asyncio.to_thread( + state.workforce.eigent_make_sub_tasks, + state.camel_task, + context_for_coordinator, + on_stream_batch, + on_stream_text, + ) + + if stream_state["subtasks"]: + sub_tasks = stream_state["subtasks"] + logger.info(f"Task decomposed into {len(sub_tasks)} subtasks") + try: + state.task_lock.decompose_sub_tasks = sub_tasks + except Exception: + pass + + # Generate task summary + summary_task_agent = task_summary_agent(state.options) + try: + new_summary = await asyncio.wait_for( + summary_task(summary_task_agent, state.camel_task), + timeout=10, + ) + state.task_lock.summary_generated = True + except TimeoutError: + logger.warning( + "summary_task timeout", + extra={ + "project_id": state.options.project_id, + "task_id": state.options.task_id, + }, + ) + state.task_lock.summary_generated = True + content_preview = ( + state.camel_task.content + if hasattr(state.camel_task, "content") + else "" + ) + if content_preview is None: + content_preview = "" + if len(content_preview) > 80: + cp = content_preview[:80] + new_summary = cp + "..." + else: + new_summary = content_preview + new_summary = f"Task|{new_summary}" + except Exception: + state.task_lock.summary_generated = True + content_preview = ( + state.camel_task.content + if hasattr(state.camel_task, "content") + else "" + ) + if content_preview is None: + content_preview = "" + if len(content_preview) > 80: + cp = content_preview[:80] + new_summary = cp + "..." + else: + new_summary = content_preview + new_summary = f"Task|{new_summary}" + + state.summary_task_content = new_summary + try: + state.task_lock.summary_task_content = new_summary + except Exception: + pass + + payload = { + "project_id": state.options.project_id, + "task_id": state.options.task_id, + "sub_tasks": tree_sub_tasks(state.camel_task.subtasks), + "delta_sub_tasks": tree_sub_tasks(sub_tasks), + "is_final": True, + "summary_task": new_summary, + } + await state.task_lock.put_queue( + ActionDecomposeProgressData(data=payload) + ) + except Exception as e: + logger.error( + f"Error in background decomposition: {e}", + exc_info=True, + ) + + +async def handle_improve_complex_task( + state: StepSolveState, + item, + question: str, + attaches_to_use: list[str], +) -> tuple[list[str], LoopControl]: + """Handle complex task: create workforce, setup camel_task, + kick off decomposition.""" + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info( + "[NEW-QUESTION] Complex task, creating workforce and decomposing" + ) + # Update the sync_step with new task_id + if hasattr(item, "new_task_id") and item.new_task_id: + set_current_task_id(state.options.project_id, item.new_task_id) + state.task_lock.summary_generated = False + + events.append(sse_json("confirmed", {"question": question})) + + context_for_coordinator = build_context_for_workforce( + state.task_lock, state.options + ) + + # Check if workforce exists - reuse it; otherwise create new one + if state.workforce is not None: + logger.debug( + "[NEW-QUESTION] Reusing " + "existing workforce " + f"(id={id(state.workforce)})" + ) + else: + logger.info("[NEW-QUESTION] Creating NEW workforce instance") + (state.workforce, state.mcp) = await construct_workforce(state.options) + for new_agent in state.options.new_agents: + state.workforce.add_single_agent_worker( + format_agent_description(new_agent), + await new_agent_model(new_agent, state.options), + ) + state.task_lock.status = Status.confirmed + + # Create camel_task for the question + clean_task_content = question + state.options.summary_prompt + state.camel_task = Task( + content=clean_task_content, id=state.options.task_id + ) + if len(attaches_to_use) > 0: + state.camel_task.additional_info = { + Path(file_path).name: file_path for file_path in attaches_to_use + } + + # Stream decomposition in background + on_stream_batch, on_stream_text, stream_state = create_stream_callbacks( + state + ) + + bg_task = asyncio.create_task( + run_decomposition_task( + state, + context_for_coordinator, + on_stream_batch, + on_stream_text, + stream_state, + ) + ) + state.task_lock.add_background_task(bg_task) + + return events, LoopControl.NORMAL diff --git a/backend/app/service/chat_service/lifecycle.py b/backend/app/service/chat_service/lifecycle.py new file mode 100644 index 000000000..54cfe0dcc --- /dev/null +++ b/backend/app/service/chat_service/lifecycle.py @@ -0,0 +1,1048 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Workforce/task CRUD, start/stop/pause, and agent construction.""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +import platform +from typing import TYPE_CHECKING + +from camel.tasks import Task +from camel.types import ModelPlatformType +from inflection import titleize +from pydash import chain + +from app.agent.agent_model import agent_model +from app.agent.factory import ( + browser_agent, + developer_agent, + document_agent, + mcp_agent, + multi_modal_agent, +) +from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, +) +from app.agent.factory.workforce_agents import ( + create_coordinator_and_task_agents, + create_new_worker_agent, +) +from app.agent.listen_chat_agent import ListenChatAgent +from app.agent.toolkit.terminal_toolkit import TerminalToolkit +from app.agent.tools import get_mcp_tools, get_toolkits +from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json +from app.service.task import ( + Action, + ActionInstallMcpData, + ActionNewAgent, + delete_task_lock, +) +from app.utils.context import check_conversation_history_length +from app.utils.event_loop_utils import set_main_event_loop +from app.utils.file_utils import get_working_directory +from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback +from app.utils.workforce import Workforce + +if TYPE_CHECKING: + from app.service.chat_service._step_solve import ( + LoopControl, + StepSolveState, + ) + +logger = logging.getLogger("chat_service") + + +# ============================================================================ +# Standalone helper functions (moved as-is from chat_service.py) +# ============================================================================ + + +async def install_mcp( + mcp: ListenChatAgent, + install_mcp: ActionInstallMcpData, +): + mcp_keys = list(install_mcp.data.get("mcpServers", {}).keys()) + logger.info(f"Installing MCP tools: {mcp_keys}") + try: + mcp.add_tools(await get_mcp_tools(install_mcp.data)) + logger.info("MCP tools installed successfully") + except Exception as e: + logger.error(f"Error installing MCP tools: {e}", exc_info=True) + raise + + +def to_sub_tasks(task: Task, summary_task_content: str): + logger.info("[TO-SUB-TASKS] 📋 Creating to_sub_tasks SSE event") + logger.info( + f"[TO-SUB-TASKS] task.id={task.id}" + f", summary={summary_task_content[:50]}" + f"..., subtasks_count=" + f"{len(task.subtasks)}" + ) + result = sse_json( + "to_sub_tasks", + { + "summary_task": summary_task_content, + "sub_tasks": tree_sub_tasks(task.subtasks), + }, + ) + logger.info("[TO-SUB-TASKS] ✅ to_sub_tasks SSE event created") + return result + + +def tree_sub_tasks(sub_tasks: list[Task], depth: int = 0): + if depth > 5: + return [] + + result = ( + chain(sub_tasks) + .filter(lambda x: x.content != "") + .map( + lambda x: { + "id": x.id, + "content": x.content, + "state": x.state, + "subtasks": tree_sub_tasks(x.subtasks, depth + 1), + } + ) + .value() + ) + + return result + + +def update_sub_tasks( + sub_tasks: list[Task], update_tasks: dict[str, TaskContent], depth: int = 0 +): + if depth > 5: # limit the depth of the recursion + return [] + + i = 0 + while i < len(sub_tasks): + item = sub_tasks[i] + if item.id in update_tasks: + item.content = update_tasks[item.id].content + update_sub_tasks(item.subtasks, update_tasks, depth + 1) + i += 1 + else: + sub_tasks.pop(i) + return sub_tasks + + +def add_sub_tasks( + camel_task: Task, update_tasks: list[TaskContent] +) -> list[Task]: + """Add new tasks (with empty id) to camel_task + and return the list of added tasks.""" + added_tasks = [] + for item in update_tasks: + if item.id == "": + new_task = Task( + content=item.content, + id=f"{camel_task.id}.{len(camel_task.subtasks) + 1}", + ) + camel_task.add_subtask(new_task) + added_tasks.append(new_task) + return added_tasks + + +def format_agent_description(agent_data: NewAgent | ActionNewAgent) -> str: + r"""Format a comprehensive agent description including name, tools, and + description. + """ + description_parts = [f"{agent_data.name}:"] + + # Add description if available + if hasattr(agent_data, "description") and agent_data.description: + description_parts.append(agent_data.description.strip()) + else: + description_parts.append("A specialized agent") + + # Add tools information + tool_names = [] + if hasattr(agent_data, "tools") and agent_data.tools: + for tool in agent_data.tools: + tool_names.append(titleize(tool)) + + if hasattr(agent_data, "mcp_tools") and agent_data.mcp_tools: + for mcp_server in agent_data.mcp_tools.get("mcpServers", {}).keys(): + tool_names.append(titleize(mcp_server)) + + if tool_names: + description_parts.append( + f"with access to {', '.join(tool_names)} tools : <{tool_names}>" + ) + + return " ".join(description_parts) + + +async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): + logger.info( + "Creating new agent", + extra={ + "agent_name": data.name, + "project_id": options.project_id, + "task_id": options.task_id, + }, + ) + logger.debug( + "New agent data", extra={"agent_data": data.model_dump_json()} + ) + working_directory = get_working_directory(options) + tool_names = [] + tools = [*await get_toolkits(data.tools, data.name, options.project_id)] + for item in data.tools: + tool_names.append(titleize(item)) + # Always include terminal_toolkit with proper working directory + terminal_toolkit = TerminalToolkit( + options.project_id, + agent_name=data.name, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(titleize("terminal_toolkit")) + if data.mcp_tools is not None: + tools = [*tools, *await get_mcp_tools(data.mcp_tools)] + for item in data.mcp_tools["mcpServers"].keys(): + tool_names.append(titleize(item)) + for item in tools: + logger.debug(f"Agent {data.name} tool: {item.func.__name__}") + logger.info( + f"Agent {data.name} created with {len(tools)} tools: {tool_names}" + ) + # Enhanced system message with platform information + enhanced_description = f"""{data.description} +- You are now working in system {platform.system()} with architecture +{platform.machine()} at working directory \ +`{working_directory}`. All local file operations \ +must occur here, but you can access files from any \ +place in the file system. For all file system \ +operations, you MUST use absolute paths to ensure \ +precision and avoid ambiguity. +The current date is {datetime.date.today()}. \ +For any date-related tasks, you MUST use this as \ +the current date. +""" + + # Pass per-agent custom model config if available + custom_model_config = getattr(data, "custom_model_config", None) + return agent_model( + data.name, + enhanced_description, + options, + tools, + tool_names=tool_names, + custom_model_config=custom_model_config, + ) + + +async def construct_workforce( + options: Chat, +) -> tuple[Workforce, ListenChatAgent]: + """Construct a workforce with all required agents. + + This function creates all agents in PARALLEL to minimize startup time. + Sync functions are run in thread pool, async functions + are awaited concurrently. + """ + logger.debug( + "construct_workforce started", + extra={"project_id": options.project_id, "task_id": options.task_id}, + ) + + # Store main event loop reference for thread-safe async task scheduling + # This allows agent_model() to schedule tasks + # when called from worker threads + set_main_event_loop(asyncio.get_running_loop()) + + working_directory = get_working_directory(options) + + # ======================================================================== + # Execute all agent creations in PARALLEL + # ======================================================================== + + try: + # asyncio.gather runs all coroutines concurrently + # asyncio.to_thread runs sync functions in + # thread pool without blocking event loop + results = await asyncio.gather( + asyncio.to_thread( + create_coordinator_and_task_agents, options, working_directory + ), + asyncio.to_thread( + create_new_worker_agent, options, working_directory + ), + asyncio.to_thread(browser_agent, options), + developer_agent(options), + document_agent(options), + asyncio.to_thread(multi_modal_agent, options), + mcp_agent(options), + ) + except Exception as e: + logger.error( + f"Failed to create agents in parallel: {e}", exc_info=True + ) + raise + finally: + # Always clear event loop reference after + # parallel agent creation completes. + # This prevents stale references and + # potential cross-request interference + set_main_event_loop(None) + + # Unpack results + ( + coord_task_agents, + new_worker_agent, + searcher, + developer, + documenter, + multi_modaler, + mcp, + ) = results + + coordinator_agent, task_agent = coord_task_agents + + # ======================================================================== + # Create Workforce instance and add workers (must be sequential) + # ======================================================================== + + try: + model_platform_enum = ModelPlatformType(options.model_platform.lower()) + except (ValueError, AttributeError): + model_platform_enum = None + + # Create workforce metrics callback for workforce analytics + workforce_metrics = WorkforceMetricsCallback( + project_id=options.project_id, task_id=options.task_id + ) + + workforce = Workforce( + options.project_id, + "A workforce", + graceful_shutdown_timeout=3, + share_memory=False, + coordinator_agent=coordinator_agent, + task_agent=task_agent, + new_worker_agent=new_worker_agent, + use_structured_output_handler=False + if model_platform_enum == ModelPlatformType.OPENAI + else True, + ) + + # Register workforce metrics callback + workforce._callbacks.append(workforce_metrics) + workforce.add_single_agent_worker( + "Developer Agent: A master-level coding assistant with a powerful " + "terminal. It can write and execute code, manage files, automate " + "desktop tasks, and deploy web applications to solve complex " + "technical challenges.", + developer, + ) + workforce.add_single_agent_worker( + "Browser Agent: Can search the web, extract webpage content, " + "simulate browser actions, and provide relevant information to " + "solve the given task.", + searcher, + ) + workforce.add_single_agent_worker( + "Document Agent: A document processing assistant skilled in creating " + "and modifying a wide range of file formats. It can generate " + "text-based files/reports (Markdown, JSON, YAML, HTML), " + "office documents (Word, PDF), presentations (PowerPoint), and " + "data files (Excel, CSV).", + documenter, + ) + workforce.add_single_agent_worker( + "Multi-Modal Agent: A specialist in media processing. It can " + "analyze images and audio, transcribe speech, download videos, and " + "generate new images from text prompts.", + multi_modaler, + ) + + return workforce, mcp + + +# ============================================================================ +# Extracted action handlers +# ============================================================================ + + +def handle_disconnect(state: StepSolveState) -> tuple[list[str], LoopControl]: + """Handle client disconnect. Returns BREAK to exit the main loop.""" + from app.service.chat_service._step_solve import LoopControl + + # This is called after checking request.is_disconnected() + # The actual async disconnect check is done in _step_solve.py + logger.warning("=" * 80) + logger.warning( + "[LIFECYCLE] CLIENT DISCONNECTED " + f"for project {state.options.project_id}" + ) + logger.warning("=" * 80) + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Stopping workforce " + "due to client disconnect, " + "workforce._running=" + f"{state.workforce._running}" + ) + if state.workforce._running: + state.workforce.stop() + state.workforce.stop_gracefully() + logger.info("[LIFECYCLE] Workforce stopped after client disconnect") + else: + logger.info("[LIFECYCLE] Workforce is None, no need to stop") + state.task_lock.status = Status.done + return [], LoopControl.BREAK + + +async def handle_disconnect_cleanup(state: StepSolveState) -> None: + """Async cleanup after disconnect (delete task lock).""" + try: + await delete_task_lock(state.task_lock.id) + logger.info("[LIFECYCLE] Task lock deleted after client disconnect") + except Exception as e: + logger.error(f"Error deleting task lock on disconnect: {e}") + + +def handle_update_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + assert state.camel_task is not None + update_tasks_map = {item.id: item for item in item.data.task} + # Use stored decomposition results if available + if not state.sub_tasks: + state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) + state.sub_tasks = update_sub_tasks(state.sub_tasks, update_tasks_map) + # Also update camel_task.subtasks to remove deleted tasks + update_sub_tasks(state.camel_task.subtasks, update_tasks_map) + # Add new tasks (with empty id) to both camel_task and sub_tasks + new_tasks = add_sub_tasks(state.camel_task, item.data.task) + state.sub_tasks.extend(new_tasks) + # Save updated sub_tasks back to task_lock + state.task_lock.decompose_sub_tasks = state.sub_tasks + summary_task_content_local = getattr( + state.task_lock, "summary_task_content", state.summary_task_content + ) + return [ + to_sub_tasks(state.camel_task, summary_task_content_local) + ], LoopControl.NORMAL + + +def handle_add_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + # Check if this might be a misrouted second question + if state.camel_task is None and state.workforce is None: + logger.error( + "Cannot add task: both " + "camel_task and workforce " + "are None for project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Cannot add task: task not " + "initialized. Please start" + " a task first." + }, + ) + ) + return events, LoopControl.CONTINUE + + assert state.camel_task is not None + if state.workforce is None: + logger.error( + "Cannot add task: workforce" + " not initialized for " + "project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Workforce not initialized." + " Please start the task " + "first." + }, + ) + ) + return events, LoopControl.CONTINUE + + # Add task to the workforce queue + state.workforce.add_task(item.content, item.task_id, item.additional_info) + + returnData = { + "project_id": item.project_id, + "task_id": item.task_id or (len(state.camel_task.subtasks) + 1), + } + events.append(sse_json("add_task", returnData)) + return events, LoopControl.NORMAL + + +def handle_remove_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + if state.workforce is None: + logger.error( + "Cannot remove task: " + "workforce not initialized " + "for project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Workforce not initialized." + " Please start the task " + "first." + }, + ) + ) + return events, LoopControl.CONTINUE + + state.workforce.remove_task(item.task_id) + returnData = { + "project_id": item.project_id, + "task_id": item.task_id, + } + events.append(sse_json("remove_task", returnData)) + return events, LoopControl.NORMAL + + +def handle_skip_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info("=" * 80) + logger.info( + "🛑 [LIFECYCLE] SKIP_TASK action received (User clicked Stop button)", + extra={ + "project_id": state.options.project_id, + "item_project_id": item.project_id, + }, + ) + logger.info("=" * 80) + + # Prevent duplicate skip processing + if state.task_lock.status == Status.done: + logger.warning( + "[LIFECYCLE] SKIP_TASK " + "received but task already " + "marked as done. Ignoring." + ) + return events, LoopControl.CONTINUE + + wf_match = ( + state.workforce is not None + and item.project_id == state.options.project_id + ) + if wf_match: + logger.info( + "[LIFECYCLE] Workforce exists" + f" (id={id(state.workforce)}), " + "state=" + f"{state.workforce._state.name}, " + f"_running={state.workforce._running}" + ) + + # Stop workforce completely + logger.info("[LIFECYCLE] 🛑 Stopping workforce") + if state.workforce._running: + # Import correct BaseWorkforce from camel + from camel.societies.workforce.workforce import ( + Workforce as BaseWorkforce, + ) + + BaseWorkforce.stop(state.workforce) + logger.info( + "[LIFECYCLE] " + "BaseWorkforce.stop() " + "completed, state=" + f"{state.workforce._state.name}, " + f"_running={state.workforce._running}" + ) + + state.workforce.stop_gracefully() + logger.info("[LIFECYCLE] ✅ Workforce stopped gracefully") + + # Clear workforce to avoid state issues + state.workforce = None + logger.info( + "[LIFECYCLE] Workforce set " + "to None, will be recreated" + " on next question" + ) + else: + logger.warning( + "[LIFECYCLE] Cannot skip: workforce is None or project_id mismatch" + ) + + # Mark task as done and preserve context + state.task_lock.status = Status.done + end_message = "Task stoppedTask stopped by user" + state.task_lock.last_task_result = end_message + + # Add to conversation history + if state.camel_task is not None: + task_content: str = state.camel_task.content + if "=== CURRENT TASK ===" in task_content: + task_content = task_content.split("=== CURRENT TASK ===")[ + -1 + ].strip() + else: + task_content: str = f"Task {state.options.task_id}" + + state.task_lock.add_conversation( + "task_result", + { + "task_content": task_content, + "task_result": end_message, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + + # Clear camel_task as well + state.camel_task = None + logger.info( + "[LIFECYCLE] Task marked as " + "done, workforce and " + "camel_task cleared, " + "ready for multi-turn" + ) + + events.append(sse_json("end", end_message)) + logger.info("[LIFECYCLE] Sent 'end' SSE event to frontend") + return events, LoopControl.NORMAL + + +def handle_start(state: StepSolveState, item) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + # Check conversation history length before starting task + is_exceeded, total_length = check_conversation_history_length( + state.task_lock + ) + if is_exceeded: + logger.error( + "Cannot start task: " + "conversation history too " + f"long ({total_length} chars)" + " for project " + f"{state.options.project_id}" + ) + ctx_msg = ( + "The conversation history " + "is too long. Please create" + " a new project to continue." + ) + events.append( + sse_json( + "context_too_long", + { + "message": ctx_msg, + "current_length": total_length, + "max_length": 100000, + }, + ) + ) + return events, LoopControl.CONTINUE + + if state.workforce is not None: + if state.workforce._state.name == "PAUSED": + # Resume paused workforce + state.workforce.resume() + return events, LoopControl.CONTINUE + else: + return events, LoopControl.CONTINUE + + state.task_lock.status = Status.processing + if not state.sub_tasks: + state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) + task = asyncio.create_task(state.workforce.eigent_start(state.sub_tasks)) + state.task_lock.add_background_task(task) + return events, LoopControl.NORMAL + + +def handle_task_state( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + # Track completed task results for the end event + task_state = item.data.get("state", "unknown") + task_result = item.data.get("result", "") + + if task_state == "DONE" and task_result: + state.last_completed_task_result = task_result + + return [sse_json("task_state", item.data)], LoopControl.NORMAL + + +async def handle_end( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] END action " + "received for project " + f"{state.options.project_id}, " + f"task {state.options.task_id}" + ) + logger.info( + "[LIFECYCLE] camel_task " + f"exists: {state.camel_task is not None}" + ", current status: " + f"{state.task_lock.status}, workforce" + f" exists: {state.workforce is not None}" + ) + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Workforce state" + " at END: _state=" + f"{state.workforce._state.name}" + ", _running=" + f"{state.workforce._running}" + ) + logger.info("=" * 80) + + # Prevent duplicate end processing + if state.task_lock.status == Status.done: + logger.warning( + "[LIFECYCLE] END action " + "received but task already " + "marked as done. Ignoring " + "duplicate END action." + ) + return events, LoopControl.CONTINUE + + if state.camel_task is None: + logger.warning( + "END action received but " + "camel_task is None for " + "project " + f"{state.options.project_id}, " + f"task {state.options.task_id}. " + "This may indicate multiple " + "END actions or improper " + "task lifecycle management." + ) + # Use item data as final result if camel_task is None + final_result: str = str(item.data) if item.data else "Task completed" + else: + final_result: str = await get_task_result_with_optional_summary( + state.camel_task, state.options + ) + + state.task_lock.status = Status.done + state.task_lock.last_task_result = final_result + + # Handle task content - use fallback if camel_task is None + if state.camel_task is not None: + task_content: str = state.camel_task.content + if "=== CURRENT TASK ===" in task_content: + task_content = task_content.split("=== CURRENT TASK ===")[ + -1 + ].strip() + else: + task_content: str = f"Task {state.options.task_id}" + + state.task_lock.add_conversation( + "task_result", + { + "task_content": task_content, + "task_result": final_result, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + + events.append(sse_json("end", final_result)) + + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Calling " + "workforce.stop_gracefully()" + " for project " + f"{state.options.project_id}, " + f"workforce id={id(state.workforce)}" + ) + state.workforce.stop_gracefully() + logger.info( + "[LIFECYCLE] Workforce " + "stopped gracefully for " + "project " + f"{state.options.project_id}" + ) + state.workforce = None + logger.info("[LIFECYCLE] Workforce set to None") + else: + logger.warning( + "[LIFECYCLE] Workforce " + "already None at end " + "action for project " + f"{state.options.project_id}" + ) + + state.camel_task = None + logger.info("[LIFECYCLE] camel_task set to None") + + if state.question_agent is not None: + state.question_agent.reset() + logger.info( + "[LIFECYCLE] question_agent" + " reset for project " + f"{state.options.project_id}" + ) + return events, LoopControl.NORMAL + + +def handle_supplement( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + if state.camel_task is None: + logger.warning( + "SUPPLEMENT action received " + "but camel_task is None for " + f"project {state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Cannot supplement task: " + "task not initialized. " + "Please start a task " + "first." + }, + ) + ) + return events, LoopControl.CONTINUE + else: + state.task_lock.status = Status.processing + state.camel_task.add_subtask( + Task( + content=item.data.question, + id=f"{state.camel_task.id}.{len(state.camel_task.subtasks)}", + ) + ) + if state.workforce is not None: + task = asyncio.create_task( + state.workforce.eigent_start(state.camel_task.subtasks) + ) + state.task_lock.add_background_task(task) + return events, LoopControl.NORMAL + + +def handle_budget_not_enough( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.pause() + return [ + sse_json(Action.budget_not_enough, {"message": "budget not enouth"}) + ], LoopControl.NORMAL + + +async def handle_stop( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] STOP action received" + " for project " + f"{state.options.project_id}" + ) + logger.info("=" * 80) + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Workforce exists " + f"(id={id(state.workforce)}), " + f"_running={state.workforce._running}" + ", _state=" + f"{state.workforce._state.name}" + ) + if state.workforce._running: + logger.info( + "[LIFECYCLE] Calling workforce.stop() because _running=True" + ) + state.workforce.stop() + logger.info("[LIFECYCLE] workforce.stop() completed") + logger.info("[LIFECYCLE] Calling workforce.stop_gracefully()") + state.workforce.stop_gracefully() + logger.info( + "[LIFECYCLE] Workforce stopped" + " for project " + f"{state.options.project_id}" + ) + else: + logger.warning( + "[LIFECYCLE] Workforce is None" + " at stop action for project" + f" {state.options.project_id}" + ) + logger.info("[LIFECYCLE] Deleting task lock") + await delete_task_lock(state.task_lock.id) + logger.info("[LIFECYCLE] Task lock deleted, breaking out of loop") + return [], LoopControl.BREAK + + +def handle_pause(state: StepSolveState, item) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.pause() + logger.info(f"Workforce paused for project {state.options.project_id}") + else: + logger.warning( + "Cannot pause: workforce is " + "None for project " + f"{state.options.project_id}" + ) + return [], LoopControl.NORMAL + + +def handle_resume( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.resume() + logger.info( + f"Workforce resumed for project {state.options.project_id}" + ) + else: + logger.warning( + "Cannot resume: workforce " + "is None for project " + f"{state.options.project_id}" + ) + return [], LoopControl.NORMAL + + +async def handle_new_agent( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.pause() + state.workforce.add_single_agent_worker( + format_agent_description(item), + await new_agent_model(item, state.options), + ) + state.workforce.resume() + return [], LoopControl.NORMAL + + +def handle_timeout( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] TIMEOUT action " + "received for project " + f"{state.options.project_id}, " + f"task {state.options.task_id}" + ) + logger.info(f"[LIFECYCLE] Timeout data: {item.data}") + logger.info("=" * 80) + + # Send timeout error to frontend + timeout_message = item.data.get("message", "Task execution timeout") + in_flight = item.data.get("in_flight_tasks", 0) + pending = item.data.get("pending_tasks", 0) + timeout_seconds = item.data.get("timeout_seconds", 0) + + return [ + sse_json( + "error", + { + "message": timeout_message, + "type": "timeout", + "details": { + "in_flight_tasks": in_flight, + "pending_tasks": pending, + "timeout_seconds": timeout_seconds, + }, + }, + ) + ], LoopControl.NORMAL + + +def handle_install_mcp( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + if state.mcp is None: + logger.error( + "Cannot install MCP: mcp " + "agent not initialized for " + "project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "MCP agent not initialized." + " Please start a complex " + "task first." + }, + ) + ) + return events, LoopControl.CONTINUE + task = asyncio.create_task(install_mcp(state.mcp, item)) + state.task_lock.add_background_task(task) + return events, LoopControl.NORMAL diff --git a/backend/app/service/chat_service/router.py b/backend/app/service/chat_service/router.py new file mode 100644 index 000000000..1ef79c551 --- /dev/null +++ b/backend/app/service/chat_service/router.py @@ -0,0 +1,573 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Question classification and routing: simple vs complex.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from camel.tasks import Task + +from app.agent.factory.question_confirm import question_confirm +from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + summary_task, + task_summary_agent, +) +from app.model.chat import Status, sse_json +from app.service.chat_service.decomposition import ( + create_stream_callbacks, + handle_improve_complex_task, +) +from app.service.chat_service.lifecycle import tree_sub_tasks +from app.service.task import ( + ActionDecomposeProgressData, + ActionImproveData, + set_current_task_id, +) +from app.utils.context import ( + build_context_for_workforce, + build_conversation_context, + check_conversation_history_length, +) +from app.utils.file_utils import get_working_directory + +if TYPE_CHECKING: + from app.service.chat_service._step_solve import ( + LoopControl, + StepSolveState, + ) + +logger = logging.getLogger("chat_service") + + +async def handle_improve( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + """Top-level router for Action.improve: extract question, + check history, classify simple vs complex, delegate.""" + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info("=" * 80) + logger.info( + "[NEW-QUESTION] Action.improve received or start_event_loop", + extra={ + "project_id": state.options.project_id, + "start_event_loop": state.start_event_loop, + }, + ) + wf_state = ( + "None" + if state.workforce is None + else f"exists(id={id(state.workforce)})" + ) + logger.info( + f"[NEW-QUESTION] Current workforce state: workforce={wf_state}" + ) + ct_state = ( + "None" + if state.camel_task is None + else f"exists(id={state.camel_task.id})" + ) + logger.info( + f"[NEW-QUESTION] Current camel_task state: camel_task={ct_state}" + ) + logger.info("=" * 80) + + if state.start_event_loop is True: + question = state.options.question + attaches_to_use = state.options.attaches + logger.info( + "[NEW-QUESTION] Initial question" + " from options.question: " + f"'{question[:100]}...'" + ) + state.start_event_loop = False + else: + assert isinstance(item, ActionImproveData) + question = item.data.question + attaches_to_use = ( + item.data.attaches + if item.data.attaches + else state.options.attaches + ) + logger.info( + "[NEW-QUESTION] Follow-up " + "question from " + "ActionImproveData: " + f"'{question[:100]}...'" + ) + + is_exceeded, total_length = check_conversation_history_length( + state.task_lock + ) + if is_exceeded: + logger.error( + "Conversation history too long", + extra={ + "project_id": state.options.project_id, + "current_length": total_length, + "max_length": 100000, + }, + ) + ctx_msg = ( + "The conversation history " + "is too long. Please create" + " a new project to continue." + ) + events.append( + sse_json( + "context_too_long", + { + "message": ctx_msg, + "current_length": total_length, + "max_length": 100000, + }, + ) + ) + return events, LoopControl.CONTINUE + + # Determine task complexity + is_complex_task: bool + if len(attaches_to_use) > 0: + is_complex_task = True + logger.info("[NEW-QUESTION] Has attachments, treating as complex task") + else: + is_complex_task = await question_confirm( + state.question_agent, question, state.task_lock + ) + logger.info( + "[NEW-QUESTION] question_confirm" + " result: is_complex=" + f"{is_complex_task}" + ) + + if not is_complex_task: + simple_events, control = await handle_improve_simple_task( + state, question + ) + events.extend(simple_events) + return events, control + else: + complex_events, control = await handle_improve_complex_task( + state, item, question, attaches_to_use + ) + events.extend(complex_events) + return events, control + + +async def handle_improve_simple_task( + state: StepSolveState, question: str +) -> tuple[list[str], LoopControl]: + """Handle simple question: direct LLM answer, folder cleanup.""" + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info( + "[NEW-QUESTION] Simple question" + ", providing direct answer " + "without workforce" + ) + conv_ctx = build_conversation_context( + state.task_lock, header="=== Previous Conversation ===" + ) + simple_answer_prompt = ( + f"{conv_ctx}" + f"User Query: {question}\n\n" + "Provide a direct, helpful " + "answer to this simple " + "question." + ) + + try: + simple_resp = state.question_agent.step(simple_answer_prompt) + if simple_resp and simple_resp.msgs: + answer_content = simple_resp.msgs[0].content + else: + answer_content = ( + "I understand your " + "question, but I'm " + "having trouble " + "generating a response " + "right now." + ) + + state.task_lock.add_conversation("assistant", answer_content) + + events.append( + sse_json( + "wait_confirm", + {"content": answer_content, "question": question}, + ) + ) + except Exception as e: + logger.error(f"Error generating simple answer: {e}") + events.append( + sse_json( + "wait_confirm", + { + "content": "I encountered an error" + " while processing " + "your question.", + "question": question, + }, + ) + ) + + # Clean up empty folder if it was created for this task + if ( + hasattr(state.task_lock, "new_folder_path") + and state.task_lock.new_folder_path + ): + try: + folder_path = Path(state.task_lock.new_folder_path) + if folder_path.exists() and folder_path.is_dir(): + # Check if folder is empty + if not any(folder_path.iterdir()): + folder_path.rmdir() + logger.info(f"Cleaned up empty folder: {folder_path}") + # Also clean up parent project folder if empty + project_folder = folder_path.parent + if project_folder.exists() and not any( + project_folder.iterdir() + ): + project_folder.rmdir() + logger.info( + "Cleaned up " + "empty project" + " folder: " + f"{project_folder}" + ) + else: + logger.info(f"Folder not empty, keeping: {folder_path}") + # Reset the folder path + state.task_lock.new_folder_path = None + except Exception as e: + logger.error(f"Error cleaning up folder: {e}") + + return events, LoopControl.CONTINUE + + +async def handle_new_task_state( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + """Handle Action.new_task_state: multi-turn question handling.""" + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] NEW_TASK_STATE action received (Multi-turn)", + extra={"project_id": state.options.project_id}, + ) + logger.info("=" * 80) + + # Log new task state details + new_task_id = item.data.get("task_id", "unknown") + new_task_state = item.data.get("state", "unknown") + logger.info( + "[LIFECYCLE] New task details" + f": task_id={new_task_id}, " + f"state={new_task_state}" + ) + + if state.camel_task is None: + logger.error( + "NEW_TASK_STATE action " + "received but camel_task " + "is None for project " + f"{state.options.project_id}, " + f"task {new_task_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Cannot process new task " + "state: current task not " + "initialized." + }, + ) + ) + return events, LoopControl.CONTINUE + + old_task_content: str = state.camel_task.content + old_task_result: str = await get_task_result_with_optional_summary( + state.camel_task, state.options + ) + + old_task_content_clean: str = old_task_content + if "=== CURRENT TASK ===" in old_task_content_clean: + old_task_content_clean = old_task_content_clean.split( + "=== CURRENT TASK ===" + )[-1].strip() + + state.task_lock.add_conversation( + "task_result", + { + "task_content": old_task_content_clean, + "task_result": old_task_result, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + + new_task_content = item.data.get("content", "") + + if new_task_content: + import time + + task_id = item.data.get("task_id", f"{int(time.time() * 1000)}-multi") + new_camel_task = Task(content=new_task_content, id=task_id) + if ( + hasattr(state.camel_task, "additional_info") + and state.camel_task.additional_info + ): + new_camel_task.additional_info = state.camel_task.additional_info + state.camel_task = new_camel_task + + # Now trigger end of previous task using stored result + events.append(sse_json("end", old_task_result)) + + # Always yield new_task_state first - this is not optional + events.append(sse_json("new_task_state", item.data)) + # Trigger Queue Removal + events.append( + sse_json("remove_task", {"task_id": item.data.get("task_id")}) + ) + + # Then handle multi-turn processing + if state.workforce is not None and new_task_content: + logger.info( + "[LIFECYCLE] Multi-turn: " + "workforce exists " + f"(id={id(state.workforce)}), " + "pausing for question " + "confirmation" + ) + state.task_lock.status = Status.confirming + state.workforce.pause() + logger.info( + "[LIFECYCLE] Multi-turn: " + "workforce paused, state=" + f"{state.workforce._state.name}" + ) + + try: + logger.info( + "[LIFECYCLE] Multi-turn: calling question_confirm for new task" + ) + is_multi_turn_complex = await question_confirm( + state.question_agent, new_task_content, state.task_lock + ) + logger.info( + "[LIFECYCLE] Multi-turn: " + "question_confirm result:" + " is_complex=" + f"{is_multi_turn_complex}" + ) + + if not is_multi_turn_complex: + logger.info( + "[LIFECYCLE] Multi-turn: " + "task is simple, providing" + " direct answer without " + "workforce" + ) + conv_ctx = build_conversation_context( + state.task_lock, + header="=== Previous Conversation ===", + ) + simple_answer_prompt = ( + f"{conv_ctx}" + "User Query: " + f"{new_task_content}" + "\n\nProvide a direct, " + "helpful answer to this " + "simple question." + ) + + try: + simple_resp = state.question_agent.step( + simple_answer_prompt + ) + if simple_resp and simple_resp.msgs: + answer_content = simple_resp.msgs[0].content + else: + answer_content = ( + "I understand your " + "question, but I'm " + "having trouble " + "generating a response" + " right now." + ) + + state.task_lock.add_conversation( + "assistant", answer_content + ) + + events.append( + sse_json( + "wait_confirm", + { + "content": answer_content, + "question": new_task_content, + }, + ) + ) + except Exception as e: + logger.error( + f"Error generating simple answer in multi-turn: {e}" + ) + events.append( + sse_json( + "wait_confirm", + { + "content": "I encountered an error " + "while processing your " + "question.", + "question": new_task_content, + }, + ) + ) + + logger.info( + "[LIFECYCLE] Multi-turn: " + "simple answer provided, " + "resuming workforce" + ) + state.workforce.resume() + logger.info( + "[LIFECYCLE] Multi-turn: " + "workforce resumed, " + "continuing to next " + "iteration" + ) + return events, LoopControl.CONTINUE + + # Update the sync_step with new task_id + logger.info( + "[LIFECYCLE] Multi-turn: " + "task is complex, setting " + f"new task_id={task_id}" + ) + set_current_task_id(state.options.project_id, task_id) + + events.append( + sse_json("confirmed", {"question": new_task_content}) + ) + state.task_lock.status = Status.confirmed + + logger.info( + "[LIFECYCLE] Multi-turn: building context for workforce" + ) + context_for_multi_turn = build_context_for_workforce( + state.task_lock, state.options + ) + + on_stream_batch, on_stream_text, stream_state = ( + create_stream_callbacks(state) + ) + + wf = state.workforce + new_sub_tasks = await wf.handle_decompose_append_task( + state.camel_task, + reset=False, + coordinator_context=context_for_multi_turn, + on_stream_batch=on_stream_batch, + on_stream_text=on_stream_text, + ) + if stream_state["subtasks"]: + new_sub_tasks = stream_state["subtasks"] + n = len(new_sub_tasks) + logger.info( + f"[LIFECYCLE] Multi-turn: task decomposed into {n} subtasks" + ) + + # Generate proper LLM summary for multi-turn tasks + try: + multi_turn_summary_agent = task_summary_agent(state.options) + new_summary_content = await asyncio.wait_for( + summary_task(multi_turn_summary_agent, state.camel_task), + timeout=10, + ) + logger.info( + "Generated LLM summary for multi-turn task", + extra={"project_id": state.options.project_id}, + ) + except TimeoutError: + logger.warning( + "Multi-turn summary_task timeout", + extra={ + "project_id": state.options.project_id, + "task_id": task_id, + }, + ) + task_content_for_summary = new_task_content + tc = task_content_for_summary + if len(tc) > 100: + new_summary_content = f"Follow-up Task|{tc[:97]}..." + else: + new_summary_content = f"Follow-up Task|{tc}" + except Exception as e: + logger.error(f"Error generating multi-turn task summary: {e}") + task_content_for_summary = new_task_content + tc = task_content_for_summary + if len(tc) > 100: + new_summary_content = f"Follow-up Task|{tc[:97]}..." + else: + new_summary_content = f"Follow-up Task|{tc}" + + # Emit final subtasks once when decomposition is complete + final_payload = { + "project_id": state.options.project_id, + "task_id": state.options.task_id, + "sub_tasks": tree_sub_tasks(state.camel_task.subtasks), + "delta_sub_tasks": tree_sub_tasks(new_sub_tasks), + "is_final": True, + "summary_task": new_summary_content, + } + await state.task_lock.put_queue( + ActionDecomposeProgressData(data=final_payload) + ) + + # Update the context with new task data + state.sub_tasks = new_sub_tasks + state.summary_task_content = new_summary_content + + except Exception as e: + import traceback + + logger.error(f"[TRACE] Traceback: {traceback.format_exc()}") + events.append( + sse_json( + "error", + {"message": f"Failed to process task: {str(e)}"}, + ) + ) + else: + if state.workforce is None: + logger.warning( + "[TRACE] Workforce is None - this might be the issue" + ) + if not new_task_content: + logger.warning("[TRACE] No new task content provided") + + return events, LoopControl.NORMAL diff --git a/backend/app/utils/context.py b/backend/app/utils/context.py new file mode 100644 index 000000000..7c47d5f08 --- /dev/null +++ b/backend/app/utils/context.py @@ -0,0 +1,242 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Shared context-building utilities for conversation history and task data.""" + +import logging + +from app.model.chat import Chat +from app.service.task import TaskLock +from app.utils.file_utils import list_files + +logger = logging.getLogger("chat_service") + + +def format_task_context( + task_data: dict, seen_files: set | None = None, skip_files: bool = False +) -> str: + """Format structured task data into a readable context string. + + Args: + task_data: Dictionary containing task content, result, + and working directory + seen_files: Optional set to track already-listed files + and avoid duplicates (deprecated, use skip_files + instead) + skip_files: If True, skip the file listing entirely + """ + context_parts = [] + + if task_data.get("task_content"): + context_parts.append(f"Previous Task: {task_data['task_content']}") + + if task_data.get("task_result"): + context_parts.append( + f"Previous Task Result: {task_data['task_result']}" + ) + + # Skip file listing if requested + if not skip_files: + working_directory = task_data.get("working_directory") + if working_directory: + try: + generated_files = list_files( + working_directory, + base=working_directory, + skip_dirs={"node_modules", "__pycache__", "venv"}, + skip_extensions=(".pyc", ".tmp"), + skip_prefix=".", + ) + if seen_files is not None: + generated_files = [ + p for p in generated_files if p not in seen_files + ] + seen_files.update(generated_files) + if generated_files: + context_parts.append("Generated Files from Previous Task:") + for file_path in sorted(generated_files): + context_parts.append(f" - {file_path}") + except Exception as e: + logger.warning(f"Failed to collect generated files: {e}") + + return "\n".join(context_parts) + + +def collect_previous_task_context( + working_directory: str, + previous_task_content: str, + previous_task_result: str, + previous_summary: str = "", +) -> str: + """ + Collect context from previous task including content, result, + summary, and generated files. + + Args: + working_directory: The working directory to scan for generated files + previous_task_content: The content of the previous task + previous_task_result: The result/output of the previous task + previous_summary: The summary of the previous task + + Returns: + Formatted context string to prepend to new task + """ + + context_parts = [] + + # Add previous task information + context_parts.append("=== CONTEXT FROM PREVIOUS TASK ===\n") + + # Add previous task content + if previous_task_content: + context_parts.append(f"Previous Task:\n{previous_task_content}\n") + + # Add previous task summary + if previous_summary: + context_parts.append(f"Previous Task Summary:\n{previous_summary}\n") + + # Add previous task result + if previous_task_result: + context_parts.append( + f"Previous Task Result:\n{previous_task_result}\n" + ) + + # Collect generated files from working directory (safe listing) + try: + generated_files = list_files( + working_directory, + base=working_directory, + skip_dirs={"node_modules", "__pycache__", "venv"}, + skip_extensions=(".pyc", ".tmp"), + skip_prefix=".", + ) + if generated_files: + context_parts.append("Generated Files from Previous Task:") + for file_path in sorted(generated_files): + context_parts.append(f" - {file_path}") + context_parts.append("") + except Exception as e: + logger.warning(f"Failed to collect generated files: {e}") + + context_parts.append("=== END OF PREVIOUS TASK CONTEXT ===\n") + + return "\n".join(context_parts) + + +def check_conversation_history_length( + task_lock: TaskLock, max_length: int = 200000 +) -> tuple[bool, int]: + """ + Check if conversation history exceeds maximum length + + Returns: + tuple: (is_exceeded, total_length) + """ + if ( + not hasattr(task_lock, "conversation_history") + or not task_lock.conversation_history + ): + return False, 0 + + total_length = 0 + for entry in task_lock.conversation_history: + total_length += len(entry.get("content", "")) + + is_exceeded = total_length > max_length + + if is_exceeded: + logger.warning( + f"Conversation history length {total_length} " + f"exceeds maximum {max_length}" + ) + + return is_exceeded, total_length + + +def build_conversation_context( + task_lock: TaskLock, header: str = "=== CONVERSATION HISTORY ===" +) -> str: + """Build conversation context from task_lock history + with files listed only once at the end. + + Args: + task_lock: TaskLock containing conversation history + header: Header text for the context section + + Returns: + Formatted context string with task history + and files listed once at the end + """ + context = "" + working_directories = set() # Collect all unique working directories + + if task_lock.conversation_history: + context = f"{header}\n" + + for entry in task_lock.conversation_history: + if entry["role"] == "task_result": + if isinstance(entry["content"], dict): + formatted_context = format_task_context( + entry["content"], skip_files=True + ) + context += formatted_context + "\n\n" + if entry["content"].get("working_directory"): + working_directories.add( + entry["content"]["working_directory"] + ) + else: + context += entry["content"] + "\n" + elif entry["role"] == "assistant": + context += f"Assistant: {entry['content']}\n\n" + + if working_directories: + all_generated_files: set[str] = set() + for working_directory in working_directories: + try: + files_list = list_files( + working_directory, + base=working_directory, + skip_dirs={"node_modules", "__pycache__", "venv"}, + skip_extensions=(".pyc", ".tmp"), + skip_prefix=".", + ) + all_generated_files.update(files_list) + except Exception as e: + logger.warning( + "Failed to collect generated " + f"files from {working_directory}: {e}" + ) + + if all_generated_files: + context += "Generated Files from Previous Tasks:\n" + for file_path in sorted(all_generated_files): + context += f" - {file_path}\n" + context += "\n" + + context += "\n" + + return context + + +def build_context_for_workforce( + task_lock: TaskLock, + options: Chat, + task_content: str | None = None, +) -> str: + """Build context information for workforce. + Instructs coordinator to actively load skills using list_skills/load_skill tools. + """ + return build_conversation_context( + task_lock, header="=== CONVERSATION HISTORY ===" + ) diff --git a/backend/tests/app/service/test_chat_service.py b/backend/tests/app/service/test_chat_service.py index ab666f3fd..527536ebc 100644 --- a/backend/tests/app/service/test_chat_service.py +++ b/backend/tests/app/service/test_chat_service.py @@ -661,10 +661,17 @@ async def test_new_agent_model_creation(self, sample_chat_data): mock_agent = MagicMock() with ( - patch("app.service.chat_service.get_toolkits", return_value=[]), - patch("app.service.chat_service.get_mcp_tools", return_value=[]), patch( - "app.service.chat_service.agent_model", return_value=mock_agent + "app.service.chat_service.lifecycle.get_toolkits", + return_value=[], + ), + patch( + "app.service.chat_service.lifecycle.get_mcp_tools", + return_value=[], + ), + patch( + "app.service.chat_service.lifecycle.agent_model", + return_value=mock_agent, ), ): result = await new_agent_model(agent_data, options) @@ -680,21 +687,26 @@ async def test_construct_workforce(self, sample_chat_data, mock_task_lock): mock_mcp_agent = MagicMock() with ( - patch("app.service.chat_service.agent_model") as mock_agent_model, patch( - "app.service.chat_service.get_working_directory", + "app.service.chat_service.lifecycle.agent_model" + ) as mock_agent_model, + patch( + "app.agent.factory.workforce_agents.agent_model" + ) as mock_wf_agent_model, + patch( + "app.service.chat_service.lifecycle.get_working_directory", return_value="/tmp/test_workdir", ), patch( - "app.service.chat_service.Workforce", + "app.service.chat_service.lifecycle.Workforce", return_value=mock_workforce, ), - patch("app.service.chat_service.browser_agent"), - patch("app.service.chat_service.developer_agent"), - patch("app.service.chat_service.document_agent"), - patch("app.service.chat_service.multi_modal_agent"), + patch("app.service.chat_service.lifecycle.browser_agent"), + patch("app.service.chat_service.lifecycle.developer_agent"), + patch("app.service.chat_service.lifecycle.document_agent"), + patch("app.service.chat_service.lifecycle.multi_modal_agent"), patch( - "app.service.chat_service.mcp_agent", + "app.service.chat_service.lifecycle.mcp_agent", return_value=mock_mcp_agent, ), patch( @@ -702,11 +714,12 @@ async def test_construct_workforce(self, sample_chat_data, mock_task_lock): return_value=mock_task_lock, ), patch( - "app.service.chat_service.WorkforceMetricsCallback", + "app.service.chat_service.lifecycle.WorkforceMetricsCallback", return_value=MagicMock(), ), ): mock_agent_model.return_value = MagicMock() + mock_wf_agent_model.return_value = MagicMock() workforce, mcp = await construct_workforce(options) @@ -725,7 +738,8 @@ async def test_install_mcp_success(self, mock_camel_agent): ) with patch( - "app.service.chat_service.get_mcp_tools", return_value=mock_tools + "app.service.chat_service.lifecycle.get_mcp_tools", + return_value=mock_tools, ): await install_mcp(mock_camel_agent, install_data) @@ -883,20 +897,21 @@ async def test_step_solve_basic_workflow( with ( patch( - "app.service.chat_service.construct_workforce", + "app.service.chat_service.decomposition.construct_workforce", return_value=(mock_workforce, mock_mcp), ), patch( - "app.service.chat_service.question_confirm_agent" + "app.service.chat_service._step_solve.question_confirm_agent" ) as mock_question_agent, patch( - "app.service.chat_service.task_summary_agent" + "app.service.chat_service.router.task_summary_agent" ) as mock_summary_agent, patch( - "app.service.chat_service.question_confirm", return_value=True + "app.service.chat_service.router.question_confirm", + return_value=True, ), patch( - "app.service.chat_service.summary_task", + "app.service.chat_service.router.summary_task", return_value="Test Summary", ), ): @@ -1222,7 +1237,11 @@ async def test_construct_workforce_agent_creation_error( return_value=mock_task_lock, ), patch( - "app.service.chat_service.agent_model", + "app.service.chat_service.lifecycle.agent_model", + side_effect=Exception("Agent creation failed"), + ), + patch( + "app.agent.factory.workforce_agents.agent_model", side_effect=Exception("Agent creation failed"), ), patch( @@ -1258,7 +1277,7 @@ async def test_new_agent_model_with_invalid_tools(self, sample_chat_data): ) with patch( - "app.service.chat_service.get_toolkits", + "app.service.chat_service.lifecycle.get_toolkits", side_effect=Exception("Invalid tool"), ): with pytest.raises(Exception, match="Invalid tool"): From f85cb9e9e13b52f2559935e72395561326049630 Mon Sep 17 00:00:00 2001 From: bytecii Date: Thu, 5 Mar 2026 00:40:48 -0800 Subject: [PATCH 2/4] refactor: refactor the chat service --- backend/app/agent/__init__.py | 4 - backend/app/agent/factory/__init__.py | 8 +- backend/app/agent/factory/question_confirm.py | 51 +- backend/app/agent/factory/task_summary.py | 72 +- backend/app/agent/prompt.py | 55 ++ backend/app/service/chat_service/__init__.py | 2 - .../app/service/chat_service/_step_solve.py | 18 +- backend/app/service/chat_service/context.py | 80 -- .../app/service/chat_service/decomposition.py | 11 +- backend/app/service/chat_service/handlers.py | 763 ++++++++++++++++++ backend/app/service/chat_service/lifecycle.py | 692 +--------------- backend/app/service/chat_service/router.py | 17 +- backend/app/utils/context.py | 16 +- .../agent/factory/test_question_confirm.py | 60 +- .../app/agent/factory/test_task_summary.py | 38 +- .../tests/app/service/test_chat_service.py | 133 ++- 16 files changed, 1009 insertions(+), 1011 deletions(-) delete mode 100644 backend/app/service/chat_service/context.py create mode 100644 backend/app/service/chat_service/handlers.py diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py index 04a7aa903..36097bf56 100644 --- a/backend/app/agent/__init__.py +++ b/backend/app/agent/__init__.py @@ -23,11 +23,9 @@ mcp_agent, multi_modal_agent, question_confirm, - question_confirm_agent, social_media_agent, summary_subtasks_result, summary_task, - task_summary_agent, ) from app.agent.listen_chat_agent import ListenChatAgent from app.agent.tools import get_mcp_tools, get_toolkits @@ -46,9 +44,7 @@ "mcp_agent", "multi_modal_agent", "question_confirm", - "question_confirm_agent", "social_media_agent", "summary_subtasks_result", "summary_task", - "task_summary_agent", ] diff --git a/backend/app/agent/factory/__init__.py b/backend/app/agent/factory/__init__.py index 9193270a9..dbfa08769 100644 --- a/backend/app/agent/factory/__init__.py +++ b/backend/app/agent/factory/__init__.py @@ -17,16 +17,12 @@ from app.agent.factory.document import document_agent from app.agent.factory.mcp import mcp_agent from app.agent.factory.multi_modal import multi_modal_agent -from app.agent.factory.question_confirm import ( - question_confirm, - question_confirm_agent, -) +from app.agent.factory.question_confirm import question_confirm from app.agent.factory.social_media import social_media_agent from app.agent.factory.task_summary import ( get_task_result_with_optional_summary, summary_subtasks_result, summary_task, - task_summary_agent, ) from app.agent.factory.workforce_agents import ( create_coordinator_and_task_agents, @@ -43,9 +39,7 @@ "mcp_agent", "multi_modal_agent", "question_confirm", - "question_confirm_agent", "social_media_agent", "summary_subtasks_result", "summary_task", - "task_summary_agent", ] diff --git a/backend/app/agent/factory/question_confirm.py b/backend/app/agent/factory/question_confirm.py index 8642f684d..aa876e83f 100644 --- a/backend/app/agent/factory/question_confirm.py +++ b/backend/app/agent/factory/question_confirm.py @@ -18,19 +18,22 @@ from typing import TYPE_CHECKING from app.agent.agent_model import agent_model -from app.agent.prompt import QUESTION_CONFIRM_SYS_PROMPT +from app.agent.prompt import ( + QUESTION_CONFIRM_PROMPT, + QUESTION_CONFIRM_SYS_PROMPT, +) from app.agent.utils import NOW_STR from app.model.chat import Chat from app.utils.context import build_conversation_context if TYPE_CHECKING: - from app.agent.listen_chat_agent import ListenChatAgent from app.service.task import TaskLock -logger = logging.getLogger("chat_service") +logger = logging.getLogger(__name__) -def question_confirm_agent(options: Chat): +def _create_question_agent(options: Chat): + """Create a question classification agent.""" return agent_model( "question_confirm_agent", QUESTION_CONFIRM_SYS_PROMPT.format(now_str=NOW_STR), @@ -39,34 +42,30 @@ def question_confirm_agent(options: Chat): async def question_confirm( - agent: ListenChatAgent, prompt: str, task_lock: TaskLock | None = None + prompt: str, options: Chat, task_lock: TaskLock ) -> bool: - """Simple question confirmation - returns True - for complex tasks, False for simple questions.""" + """Classify whether a user query is a complex task or simple question. - context_prompt = "" - if task_lock: - context_prompt = build_conversation_context( - task_lock, header="=== Previous Conversation ===" - ) - - full_prompt = f"""{context_prompt}User Query: {prompt} - -Determine if this user query is a complex task or a simple question. + Creates and caches the question agent on task_lock.question_agent + for reuse across multi-turn conversations. -**Complex task** (answer "yes"): Requires tools, code execution, \ -file operations, multi-step planning, or creating/modifying content -- Examples: "create a file", "search for X", \ -"implement feature Y", "write code", "analyze data" + Returns True for complex tasks, False for simple questions. + """ + if ( + not hasattr(task_lock, "question_agent") + or task_lock.question_agent is None + ): + task_lock.question_agent = _create_question_agent(options) -**Simple question** (answer "no"): Can be answered directly \ -with knowledge or conversation history, no action needed -- Examples: greetings ("hello", "hi"), \ -fact queries ("what is X?"), clarifications, status checks + agent = task_lock.question_agent -Answer only "yes" or "no". Do not provide any explanation. + context_prompt = build_conversation_context( + task_lock, header="=== Previous Conversation ===" + ) -Is this a complex task? (yes/no):""" + full_prompt = QUESTION_CONFIRM_PROMPT.format( + context_prompt=context_prompt, user_query=prompt + ) try: resp = agent.step(full_prompt) diff --git a/backend/app/agent/factory/task_summary.py b/backend/app/agent/factory/task_summary.py index 78bfed496..ad579859c 100644 --- a/backend/app/agent/factory/task_summary.py +++ b/backend/app/agent/factory/task_summary.py @@ -15,21 +15,22 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING from camel.tasks import Task from app.agent.agent_model import agent_model -from app.agent.prompt import TASK_SUMMARY_SYS_PROMPT +from app.agent.prompt import ( + SUBTASKS_SUMMARY_PROMPT, + TASK_SUMMARY_PROMPT, + TASK_SUMMARY_SYS_PROMPT, +) from app.model.chat import Chat -if TYPE_CHECKING: - from app.agent.listen_chat_agent import ListenChatAgent +logger = logging.getLogger(__name__) -logger = logging.getLogger("chat_service") - -def task_summary_agent(options: Chat): +def _create_summary_agent(options: Chat): + """Create a task summary agent.""" return agent_model( "task_summary_agent", TASK_SUMMARY_SYS_PROMPT, @@ -37,19 +38,10 @@ def task_summary_agent(options: Chat): ) -async def summary_task(agent: ListenChatAgent, task: Task) -> str: - prompt = f"""The user's task is: ---- -{task.to_string()} ---- -Your instructions are: -1. Come up with a short and descriptive name for this task. -2. Create a concise summary of the task's main points and objectives. -3. Return the task name and the summary, separated by a vertical bar (|). - -Example format: "Task Name|This is the summary of the task." -Do not include any other text or formatting. -""" +async def summary_task(task: Task, options: Chat) -> str: + """Generate a short name and summary for a task.""" + agent = _create_summary_agent(options) + prompt = TASK_SUMMARY_PROMPT.format(task_string=task.to_string()) logger.debug("Generating task summary", extra={"task_id": task.id}) try: res = agent.step(prompt) @@ -65,17 +57,18 @@ async def summary_task(agent: ListenChatAgent, task: Task) -> str: raise -async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: - """ - Summarize the aggregated results from all subtasks into a concise summary. +async def summary_subtasks_result(task: Task, options: Chat) -> str: + """Summarize the aggregated results from all subtasks. Args: - agent: The summary agent to use task: The main task containing subtasks and their aggregated results + options: Chat options for creating the summary agent Returns: A concise summary of all subtask results """ + agent = _create_summary_agent(options) + subtasks_info = "" for i, subtask in enumerate(task.subtasks, 1): subtasks_info += f"\n**Subtask {i}**\n" @@ -83,26 +76,9 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: subtasks_info += f"Result: {subtask.result or 'No result'}\n" subtasks_info += "---\n" - prompt = f"""You are a professional summarizer. \ -Summarize the results of the following subtasks. - -Main Task: {task.content} - -Subtasks (with descriptions and results): ---- -{subtasks_info} ---- - -Instructions: -1. Provide a concise summary of what was accomplished -2. Highlight key findings or outputs from each subtask -3. Mention any important files created or actions taken -4. Use bullet points or sections for clarity -5. DO NOT repeat the task name in your summary - go straight to the results -6. Keep it professional but conversational - -Summary: -""" + prompt = SUBTASKS_SUMMARY_PROMPT.format( + task_content=task.content, subtasks_info=subtasks_info + ) res = agent.step(prompt) summary = res.msgs[0].content @@ -119,8 +95,7 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: async def get_task_result_with_optional_summary( task: Task, options: Chat ) -> str: - """ - Get the task result, with LLM summary if there are multiple subtasks. + """Get the task result, with LLM summary if there are multiple subtasks. Args: task: The task to get result from @@ -138,10 +113,7 @@ async def get_task_result_with_optional_summary( "generating summary" ) try: - summary_agent = task_summary_agent(options) - summarized_result = await summary_subtasks_result( - summary_agent, task - ) + summarized_result = await summary_subtasks_result(task, options) result = summarized_result logger.info(f"Successfully generated summary for task {task.id}") except Exception as e: diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 77bd9e2fe..9fbef2e40 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -699,6 +699,61 @@ robot checks), you MUST request help using the human toolkit. """ +QUESTION_CONFIRM_PROMPT = """\ +{context_prompt}User Query: {user_query} + +Determine if this user query is a complex task or a simple question. + +**Complex task** (answer "yes"): Requires tools, code execution, \ +file operations, multi-step planning, or creating/modifying content +- Examples: "create a file", "search for X", \ +"implement feature Y", "write code", "analyze data" + +**Simple question** (answer "no"): Can be answered directly \ +with knowledge or conversation history, no action needed +- Examples: greetings ("hello", "hi"), \ +fact queries ("what is X?"), clarifications, status checks + +Answer only "yes" or "no". Do not provide any explanation. + +Is this a complex task? (yes/no):""" + +TASK_SUMMARY_PROMPT = """\ +The user's task is: +--- +{task_string} +--- +Your instructions are: +1. Come up with a short and descriptive name for this task. +2. Create a concise summary of the task's main points and objectives. +3. Return the task name and the summary, separated by a vertical bar (|). + +Example format: "Task Name|This is the summary of the task." +Do not include any other text or formatting. +""" + +SUBTASKS_SUMMARY_PROMPT = """\ +You are a professional summarizer. \ +Summarize the results of the following subtasks. + +Main Task: {task_content} + +Subtasks (with descriptions and results): +--- +{subtasks_info} +--- + +Instructions: +1. Provide a concise summary of what was accomplished +2. Highlight key findings or outputs from each subtask +3. Mention any important files created or actions taken +4. Use bullet points or sections for clarity +5. DO NOT repeat the task name in your summary - go straight to the results +6. Keep it professional but conversational + +Summary: +""" + DEFAULT_SUMMARY_PROMPT = ( "After completing the task, please generate" " a summary of the entire task completion. " diff --git a/backend/app/service/chat_service/__init__.py b/backend/app/service/chat_service/__init__.py index 6c59039a7..388417f59 100644 --- a/backend/app/service/chat_service/__init__.py +++ b/backend/app/service/chat_service/__init__.py @@ -32,7 +32,6 @@ update_sub_tasks, ) from app.utils.context import ( - build_context_for_workforce, build_conversation_context, check_conversation_history_length, collect_previous_task_context, @@ -41,7 +40,6 @@ __all__ = [ "step_solve", - "build_context_for_workforce", "build_conversation_context", "check_conversation_history_length", "collect_previous_task_context", diff --git a/backend/app/service/chat_service/_step_solve.py b/backend/app/service/chat_service/_step_solve.py index 3d58b341e..853488dc0 100644 --- a/backend/app/service/chat_service/_step_solve.py +++ b/backend/app/service/chat_service/_step_solve.py @@ -23,11 +23,9 @@ from camel.tasks import Task from fastapi import Request -from app.agent.factory import question_confirm_agent from app.agent.listen_chat_agent import ListenChatAgent from app.model.chat import Chat, sse_json -from app.service.chat_service.context import handle_passthrough_event -from app.service.chat_service.lifecycle import ( +from app.service.chat_service.handlers import ( handle_add_task, handle_budget_not_enough, handle_disconnect, @@ -35,6 +33,7 @@ handle_end, handle_install_mcp, handle_new_agent, + handle_passthrough_event, handle_pause, handle_remove_task, handle_resume, @@ -75,7 +74,6 @@ class StepSolveState: workforce: Workforce | None = None camel_task: Task | None = None mcp: ListenChatAgent | None = None - question_agent: ListenChatAgent | None = None sub_tasks: list[Task] = field(default_factory=list) summary_task_content: str = "" last_completed_task_result: str = "" @@ -84,7 +82,7 @@ class StepSolveState: def _initialize_state(state: StepSolveState) -> None: - """Initialize task_lock attributes and question_agent.""" + """Initialize task_lock attributes.""" if not hasattr(state.task_lock, "conversation_history"): state.task_lock.conversation_history = [] if not hasattr(state.task_lock, "last_task_result"): @@ -94,16 +92,6 @@ def _initialize_state(state: StepSolveState) -> None: if not hasattr(state.task_lock, "summary_generated"): state.task_lock.summary_generated = False - # Create or reuse persistent question_agent - if state.task_lock.question_agent is None: - state.task_lock.question_agent = question_confirm_agent(state.options) - else: - hist_len = len(state.task_lock.conversation_history) - logger.debug( - f"Reusing existing question_agent with {hist_len} history entries" - ) - - state.question_agent = state.task_lock.question_agent state.event_loop = asyncio.get_running_loop() diff --git a/backend/app/service/chat_service/context.py b/backend/app/service/chat_service/context.py deleted file mode 100644 index 8a99ac079..000000000 --- a/backend/app/service/chat_service/context.py +++ /dev/null @@ -1,80 +0,0 @@ -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - -"""Passthrough SSE event handling + re-exports of shared context utilities.""" - -from app.model.chat import sse_json -from app.service.task import Action - -# Re-export context utilities from shared location -from app.utils.context import ( # noqa: F401 - build_context_for_workforce, - build_conversation_context, - check_conversation_history_length, - collect_previous_task_context, - format_task_context, -) - - -def handle_passthrough_event(item) -> str | None: - """Handle simple passthrough events that just forward data as SSE. - - Returns: - SSE JSON string if the action is a passthrough, None otherwise. - """ - if item.action == Action.create_agent: - return sse_json("create_agent", item.data) - elif item.action == Action.activate_agent: - return sse_json("activate_agent", item.data) - elif item.action == Action.deactivate_agent: - return sse_json("deactivate_agent", dict(item.data)) - elif item.action == Action.assign_task: - return sse_json("assign_task", item.data) - elif item.action == Action.activate_toolkit: - return sse_json("activate_toolkit", item.data) - elif item.action == Action.deactivate_toolkit: - return sse_json("deactivate_toolkit", item.data) - elif item.action == Action.write_file: - return sse_json( - "write_file", - { - "file_path": item.data, - "process_task_id": item.process_task_id, - }, - ) - elif item.action == Action.ask: - return sse_json("ask", item.data) - elif item.action == Action.notice: - return sse_json( - "notice", - { - "notice": item.data, - "process_task_id": item.process_task_id, - }, - ) - elif item.action == Action.search_mcp: - return sse_json("search_mcp", item.data) - elif item.action == Action.terminal: - return sse_json( - "terminal", - { - "output": item.data, - "process_task_id": item.process_task_id, - }, - ) - elif item.action == Action.decompose_text: - return sse_json("decompose_text", item.data) - elif item.action == Action.decompose_progress: - return sse_json("to_sub_tasks", item.data) - return None diff --git a/backend/app/service/chat_service/decomposition.py b/backend/app/service/chat_service/decomposition.py index a42d8a882..a20581c7c 100644 --- a/backend/app/service/chat_service/decomposition.py +++ b/backend/app/service/chat_service/decomposition.py @@ -23,7 +23,7 @@ from camel.tasks import Task -from app.agent.factory.task_summary import summary_task, task_summary_agent +from app.agent.factory.task_summary import summary_task from app.model.chat import Status, sse_json from app.service.chat_service.lifecycle import ( construct_workforce, @@ -36,7 +36,7 @@ ActionDecomposeTextData, set_current_task_id, ) -from app.utils.context import build_context_for_workforce +from app.utils.context import build_conversation_context if TYPE_CHECKING: from app.service.chat_service._step_solve import ( @@ -132,10 +132,9 @@ async def run_decomposition_task( pass # Generate task summary - summary_task_agent = task_summary_agent(state.options) try: new_summary = await asyncio.wait_for( - summary_task(summary_task_agent, state.camel_task), + summary_task(state.camel_task, state.options), timeout=10, ) state.task_lock.summary_generated = True @@ -222,9 +221,7 @@ async def handle_improve_complex_task( events.append(sse_json("confirmed", {"question": question})) - context_for_coordinator = build_context_for_workforce( - state.task_lock, state.options - ) + context_for_coordinator = build_conversation_context(state.task_lock) # Check if workforce exists - reuse it; otherwise create new one if state.workforce is not None: diff --git a/backend/app/service/chat_service/handlers.py b/backend/app/service/chat_service/handlers.py new file mode 100644 index 000000000..3620ec852 --- /dev/null +++ b/backend/app/service/chat_service/handlers.py @@ -0,0 +1,763 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Event handlers for the step_solve dispatch loop.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from camel.tasks import Task + +from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, +) +from app.model.chat import Status, sse_json +from app.service.chat_service.lifecycle import ( + add_sub_tasks, + format_agent_description, + install_mcp, + new_agent_model, + to_sub_tasks, + update_sub_tasks, +) +from app.service.task import Action, delete_task_lock +from app.utils.context import check_conversation_history_length +from app.utils.file_utils import get_working_directory + +if TYPE_CHECKING: + from app.service.chat_service._step_solve import ( + LoopControl, + StepSolveState, + ) + +logger = logging.getLogger(__name__) + + +def handle_passthrough_event(item) -> str | None: + """Handle simple passthrough events that just forward data as SSE. + + Returns: + SSE JSON string if the action is a passthrough, None otherwise. + """ + if item.action == Action.create_agent: + return sse_json("create_agent", item.data) + elif item.action == Action.activate_agent: + return sse_json("activate_agent", item.data) + elif item.action == Action.deactivate_agent: + return sse_json("deactivate_agent", dict(item.data)) + elif item.action == Action.assign_task: + return sse_json("assign_task", item.data) + elif item.action == Action.activate_toolkit: + return sse_json("activate_toolkit", item.data) + elif item.action == Action.deactivate_toolkit: + return sse_json("deactivate_toolkit", item.data) + elif item.action == Action.write_file: + return sse_json( + "write_file", + { + "file_path": item.data, + "process_task_id": item.process_task_id, + }, + ) + elif item.action == Action.ask: + return sse_json("ask", item.data) + elif item.action == Action.notice: + return sse_json( + "notice", + { + "notice": item.data, + "process_task_id": item.process_task_id, + }, + ) + elif item.action == Action.search_mcp: + return sse_json("search_mcp", item.data) + elif item.action == Action.terminal: + return sse_json( + "terminal", + { + "output": item.data, + "process_task_id": item.process_task_id, + }, + ) + elif item.action == Action.decompose_text: + return sse_json("decompose_text", item.data) + elif item.action == Action.decompose_progress: + return sse_json("to_sub_tasks", item.data) + return None + + +def handle_disconnect(state: StepSolveState) -> tuple[list[str], LoopControl]: + """Handle client disconnect. Returns BREAK to exit the main loop.""" + from app.service.chat_service._step_solve import LoopControl + + # This is called after checking request.is_disconnected() + # The actual async disconnect check is done in _step_solve.py + logger.warning("=" * 80) + logger.warning( + "[LIFECYCLE] CLIENT DISCONNECTED " + f"for project {state.options.project_id}" + ) + logger.warning("=" * 80) + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Stopping workforce " + "due to client disconnect, " + "workforce._running=" + f"{state.workforce._running}" + ) + if state.workforce._running: + state.workforce.stop() + state.workforce.stop_gracefully() + logger.info("[LIFECYCLE] Workforce stopped after client disconnect") + else: + logger.info("[LIFECYCLE] Workforce is None, no need to stop") + state.task_lock.status = Status.done + return [], LoopControl.BREAK + + +async def handle_disconnect_cleanup(state: StepSolveState) -> None: + """Async cleanup after disconnect (delete task lock).""" + try: + await delete_task_lock(state.task_lock.id) + logger.info("[LIFECYCLE] Task lock deleted after client disconnect") + except Exception as e: + logger.error(f"Error deleting task lock on disconnect: {e}") + + +def handle_update_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + assert state.camel_task is not None + update_tasks_map = {item.id: item for item in item.data.task} + # Use stored decomposition results if available + if not state.sub_tasks: + state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) + state.sub_tasks = update_sub_tasks(state.sub_tasks, update_tasks_map) + # Also update camel_task.subtasks to remove deleted tasks + update_sub_tasks(state.camel_task.subtasks, update_tasks_map) + # Add new tasks (with empty id) to both camel_task and sub_tasks + new_tasks = add_sub_tasks(state.camel_task, item.data.task) + state.sub_tasks.extend(new_tasks) + # Save updated sub_tasks back to task_lock + state.task_lock.decompose_sub_tasks = state.sub_tasks + summary_task_content_local = getattr( + state.task_lock, "summary_task_content", state.summary_task_content + ) + return [ + to_sub_tasks(state.camel_task, summary_task_content_local) + ], LoopControl.NORMAL + + +def handle_add_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + # Check if this might be a misrouted second question + if state.camel_task is None and state.workforce is None: + logger.error( + "Cannot add task: both " + "camel_task and workforce " + "are None for project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Cannot add task: task not " + "initialized. Please start" + " a task first." + }, + ) + ) + return events, LoopControl.CONTINUE + + assert state.camel_task is not None + if state.workforce is None: + logger.error( + "Cannot add task: workforce" + " not initialized for " + "project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Workforce not initialized." + " Please start the task " + "first." + }, + ) + ) + return events, LoopControl.CONTINUE + + # Add task to the workforce queue + state.workforce.add_task(item.content, item.task_id, item.additional_info) + + returnData = { + "project_id": item.project_id, + "task_id": item.task_id or (len(state.camel_task.subtasks) + 1), + } + events.append(sse_json("add_task", returnData)) + return events, LoopControl.NORMAL + + +def handle_remove_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + if state.workforce is None: + logger.error( + "Cannot remove task: " + "workforce not initialized " + "for project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Workforce not initialized." + " Please start the task " + "first." + }, + ) + ) + return events, LoopControl.CONTINUE + + state.workforce.remove_task(item.task_id) + returnData = { + "project_id": item.project_id, + "task_id": item.task_id, + } + events.append(sse_json("remove_task", returnData)) + return events, LoopControl.NORMAL + + +def handle_skip_task( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info("=" * 80) + logger.info( + "🛑 [LIFECYCLE] SKIP_TASK action received (User clicked Stop button)", + extra={ + "project_id": state.options.project_id, + "item_project_id": item.project_id, + }, + ) + logger.info("=" * 80) + + # Prevent duplicate skip processing + if state.task_lock.status == Status.done: + logger.warning( + "[LIFECYCLE] SKIP_TASK " + "received but task already " + "marked as done. Ignoring." + ) + return events, LoopControl.CONTINUE + + wf_match = ( + state.workforce is not None + and item.project_id == state.options.project_id + ) + if wf_match: + logger.info( + "[LIFECYCLE] Workforce exists" + f" (id={id(state.workforce)}), " + "state=" + f"{state.workforce._state.name}, " + f"_running={state.workforce._running}" + ) + + # Stop workforce completely + logger.info("[LIFECYCLE] 🛑 Stopping workforce") + if state.workforce._running: + # Import correct BaseWorkforce from camel + from camel.societies.workforce.workforce import ( + Workforce as BaseWorkforce, + ) + + BaseWorkforce.stop(state.workforce) + logger.info( + "[LIFECYCLE] " + "BaseWorkforce.stop() " + "completed, state=" + f"{state.workforce._state.name}, " + f"_running={state.workforce._running}" + ) + + state.workforce.stop_gracefully() + logger.info("[LIFECYCLE] ✅ Workforce stopped gracefully") + + # Clear workforce to avoid state issues + state.workforce = None + logger.info( + "[LIFECYCLE] Workforce set " + "to None, will be recreated" + " on next question" + ) + else: + logger.warning( + "[LIFECYCLE] Cannot skip: workforce is None or project_id mismatch" + ) + + # Mark task as done and preserve context + state.task_lock.status = Status.done + end_message = "Task stoppedTask stopped by user" + state.task_lock.last_task_result = end_message + + # Add to conversation history + if state.camel_task is not None: + task_content: str = state.camel_task.content + if "=== CURRENT TASK ===" in task_content: + task_content = task_content.split("=== CURRENT TASK ===")[ + -1 + ].strip() + else: + task_content: str = f"Task {state.options.task_id}" + + state.task_lock.add_conversation( + "task_result", + { + "task_content": task_content, + "task_result": end_message, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + + # Clear camel_task as well + state.camel_task = None + logger.info( + "[LIFECYCLE] Task marked as " + "done, workforce and " + "camel_task cleared, " + "ready for multi-turn" + ) + + events.append(sse_json("end", end_message)) + logger.info("[LIFECYCLE] Sent 'end' SSE event to frontend") + return events, LoopControl.NORMAL + + +def handle_start(state: StepSolveState, item) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + # Check conversation history length before starting task + is_exceeded, total_length = check_conversation_history_length( + state.task_lock + ) + if is_exceeded: + logger.error( + "Cannot start task: " + "conversation history too " + f"long ({total_length} chars)" + " for project " + f"{state.options.project_id}" + ) + ctx_msg = ( + "The conversation history " + "is too long. Please create" + " a new project to continue." + ) + events.append( + sse_json( + "context_too_long", + { + "message": ctx_msg, + "current_length": total_length, + "max_length": 100000, + }, + ) + ) + return events, LoopControl.CONTINUE + + if state.workforce is not None: + if state.workforce._state.name == "PAUSED": + # Resume paused workforce + state.workforce.resume() + return events, LoopControl.CONTINUE + else: + return events, LoopControl.CONTINUE + + state.task_lock.status = Status.processing + if not state.sub_tasks: + state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) + task = asyncio.create_task(state.workforce.eigent_start(state.sub_tasks)) + state.task_lock.add_background_task(task) + return events, LoopControl.NORMAL + + +def handle_task_state( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + # Track completed task results for the end event + task_state = item.data.get("state", "unknown") + task_result = item.data.get("result", "") + + if task_state == "DONE" and task_result: + state.last_completed_task_result = task_result + + return [sse_json("task_state", item.data)], LoopControl.NORMAL + + +async def handle_end( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] END action " + "received for project " + f"{state.options.project_id}, " + f"task {state.options.task_id}" + ) + logger.info( + "[LIFECYCLE] camel_task " + f"exists: {state.camel_task is not None}" + ", current status: " + f"{state.task_lock.status}, workforce" + f" exists: {state.workforce is not None}" + ) + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Workforce state" + " at END: _state=" + f"{state.workforce._state.name}" + ", _running=" + f"{state.workforce._running}" + ) + logger.info("=" * 80) + + # Prevent duplicate end processing + if state.task_lock.status == Status.done: + logger.warning( + "[LIFECYCLE] END action " + "received but task already " + "marked as done. Ignoring " + "duplicate END action." + ) + return events, LoopControl.CONTINUE + + if state.camel_task is None: + logger.warning( + "END action received but " + "camel_task is None for " + "project " + f"{state.options.project_id}, " + f"task {state.options.task_id}. " + "This may indicate multiple " + "END actions or improper " + "task lifecycle management." + ) + # Use item data as final result if camel_task is None + final_result: str = str(item.data) if item.data else "Task completed" + else: + final_result: str = await get_task_result_with_optional_summary( + state.camel_task, state.options + ) + + state.task_lock.status = Status.done + state.task_lock.last_task_result = final_result + + # Handle task content - use fallback if camel_task is None + if state.camel_task is not None: + task_content: str = state.camel_task.content + if "=== CURRENT TASK ===" in task_content: + task_content = task_content.split("=== CURRENT TASK ===")[ + -1 + ].strip() + else: + task_content: str = f"Task {state.options.task_id}" + + state.task_lock.add_conversation( + "task_result", + { + "task_content": task_content, + "task_result": final_result, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + + events.append(sse_json("end", final_result)) + + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Calling " + "workforce.stop_gracefully()" + " for project " + f"{state.options.project_id}, " + f"workforce id={id(state.workforce)}" + ) + state.workforce.stop_gracefully() + logger.info( + "[LIFECYCLE] Workforce " + "stopped gracefully for " + "project " + f"{state.options.project_id}" + ) + state.workforce = None + logger.info("[LIFECYCLE] Workforce set to None") + else: + logger.warning( + "[LIFECYCLE] Workforce " + "already None at end " + "action for project " + f"{state.options.project_id}" + ) + + state.camel_task = None + logger.info("[LIFECYCLE] camel_task set to None") + + if state.task_lock.question_agent is not None: + state.task_lock.question_agent.reset() + logger.info( + "[LIFECYCLE] question_agent" + " reset for project " + f"{state.options.project_id}" + ) + return events, LoopControl.NORMAL + + +def handle_supplement( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + if state.camel_task is None: + logger.warning( + "SUPPLEMENT action received " + "but camel_task is None for " + f"project {state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Cannot supplement task: " + "task not initialized. " + "Please start a task " + "first." + }, + ) + ) + return events, LoopControl.CONTINUE + else: + state.task_lock.status = Status.processing + state.camel_task.add_subtask( + Task( + content=item.data.question, + id=f"{state.camel_task.id}.{len(state.camel_task.subtasks)}", + ) + ) + if state.workforce is not None: + task = asyncio.create_task( + state.workforce.eigent_start(state.camel_task.subtasks) + ) + state.task_lock.add_background_task(task) + return events, LoopControl.NORMAL + + +def handle_budget_not_enough( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.pause() + return [ + sse_json(Action.budget_not_enough, {"message": "budget not enouth"}) + ], LoopControl.NORMAL + + +async def handle_stop( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] STOP action received" + " for project " + f"{state.options.project_id}" + ) + logger.info("=" * 80) + if state.workforce is not None: + logger.info( + "[LIFECYCLE] Workforce exists " + f"(id={id(state.workforce)}), " + f"_running={state.workforce._running}" + ", _state=" + f"{state.workforce._state.name}" + ) + if state.workforce._running: + logger.info( + "[LIFECYCLE] Calling workforce.stop() because _running=True" + ) + state.workforce.stop() + logger.info("[LIFECYCLE] workforce.stop() completed") + logger.info("[LIFECYCLE] Calling workforce.stop_gracefully()") + state.workforce.stop_gracefully() + logger.info( + "[LIFECYCLE] Workforce stopped" + " for project " + f"{state.options.project_id}" + ) + else: + logger.warning( + "[LIFECYCLE] Workforce is None" + " at stop action for project" + f" {state.options.project_id}" + ) + logger.info("[LIFECYCLE] Deleting task lock") + await delete_task_lock(state.task_lock.id) + logger.info("[LIFECYCLE] Task lock deleted, breaking out of loop") + return [], LoopControl.BREAK + + +def handle_pause(state: StepSolveState, item) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.pause() + logger.info(f"Workforce paused for project {state.options.project_id}") + else: + logger.warning( + "Cannot pause: workforce is " + "None for project " + f"{state.options.project_id}" + ) + return [], LoopControl.NORMAL + + +def handle_resume( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.resume() + logger.info( + f"Workforce resumed for project {state.options.project_id}" + ) + else: + logger.warning( + "Cannot resume: workforce " + "is None for project " + f"{state.options.project_id}" + ) + return [], LoopControl.NORMAL + + +async def handle_new_agent( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + if state.workforce is not None: + state.workforce.pause() + state.workforce.add_single_agent_worker( + format_agent_description(item), + await new_agent_model(item, state.options), + ) + state.workforce.resume() + return [], LoopControl.NORMAL + + +def handle_timeout( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + logger.info("=" * 80) + logger.info( + "[LIFECYCLE] TIMEOUT action " + "received for project " + f"{state.options.project_id}, " + f"task {state.options.task_id}" + ) + logger.info(f"[LIFECYCLE] Timeout data: {item.data}") + logger.info("=" * 80) + + # Send timeout error to frontend + timeout_message = item.data.get("message", "Task execution timeout") + in_flight = item.data.get("in_flight_tasks", 0) + pending = item.data.get("pending_tasks", 0) + timeout_seconds = item.data.get("timeout_seconds", 0) + + return [ + sse_json( + "error", + { + "message": timeout_message, + "type": "timeout", + "details": { + "in_flight_tasks": in_flight, + "pending_tasks": pending, + "timeout_seconds": timeout_seconds, + }, + }, + ) + ], LoopControl.NORMAL + + +def handle_install_mcp( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + from app.service.chat_service._step_solve import LoopControl + + events = [] + if state.mcp is None: + logger.error( + "Cannot install MCP: mcp " + "agent not initialized for " + "project " + f"{state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "MCP agent not initialized." + " Please start a complex " + "task first." + }, + ) + ) + return events, LoopControl.CONTINUE + task = asyncio.create_task(install_mcp(state.mcp, item)) + state.task_lock.add_background_task(task) + return events, LoopControl.NORMAL diff --git a/backend/app/service/chat_service/lifecycle.py b/backend/app/service/chat_service/lifecycle.py index 54cfe0dcc..3545ded8e 100644 --- a/backend/app/service/chat_service/lifecycle.py +++ b/backend/app/service/chat_service/lifecycle.py @@ -12,7 +12,7 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Workforce/task CRUD, start/stop/pause, and agent construction.""" +"""Workforce setup, task CRUD helpers, and agent construction.""" from __future__ import annotations @@ -20,7 +20,6 @@ import datetime import logging import platform -from typing import TYPE_CHECKING from camel.tasks import Task from camel.types import ModelPlatformType @@ -35,9 +34,6 @@ mcp_agent, multi_modal_agent, ) -from app.agent.factory.task_summary import ( - get_task_result_with_optional_summary, -) from app.agent.factory.workforce_agents import ( create_coordinator_and_task_agents, create_new_worker_agent, @@ -45,26 +41,14 @@ from app.agent.listen_chat_agent import ListenChatAgent from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.tools import get_mcp_tools, get_toolkits -from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json -from app.service.task import ( - Action, - ActionInstallMcpData, - ActionNewAgent, - delete_task_lock, -) -from app.utils.context import check_conversation_history_length +from app.model.chat import Chat, NewAgent, TaskContent, sse_json +from app.service.task import ActionInstallMcpData, ActionNewAgent from app.utils.event_loop_utils import set_main_event_loop from app.utils.file_utils import get_working_directory from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback from app.utils.workforce import Workforce -if TYPE_CHECKING: - from app.service.chat_service._step_solve import ( - LoopControl, - StepSolveState, - ) - -logger = logging.getLogger("chat_service") +logger = logging.getLogger(__name__) # ============================================================================ @@ -378,671 +362,3 @@ async def construct_workforce( ) return workforce, mcp - - -# ============================================================================ -# Extracted action handlers -# ============================================================================ - - -def handle_disconnect(state: StepSolveState) -> tuple[list[str], LoopControl]: - """Handle client disconnect. Returns BREAK to exit the main loop.""" - from app.service.chat_service._step_solve import LoopControl - - # This is called after checking request.is_disconnected() - # The actual async disconnect check is done in _step_solve.py - logger.warning("=" * 80) - logger.warning( - "[LIFECYCLE] CLIENT DISCONNECTED " - f"for project {state.options.project_id}" - ) - logger.warning("=" * 80) - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Stopping workforce " - "due to client disconnect, " - "workforce._running=" - f"{state.workforce._running}" - ) - if state.workforce._running: - state.workforce.stop() - state.workforce.stop_gracefully() - logger.info("[LIFECYCLE] Workforce stopped after client disconnect") - else: - logger.info("[LIFECYCLE] Workforce is None, no need to stop") - state.task_lock.status = Status.done - return [], LoopControl.BREAK - - -async def handle_disconnect_cleanup(state: StepSolveState) -> None: - """Async cleanup after disconnect (delete task lock).""" - try: - await delete_task_lock(state.task_lock.id) - logger.info("[LIFECYCLE] Task lock deleted after client disconnect") - except Exception as e: - logger.error(f"Error deleting task lock on disconnect: {e}") - - -def handle_update_task( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - assert state.camel_task is not None - update_tasks_map = {item.id: item for item in item.data.task} - # Use stored decomposition results if available - if not state.sub_tasks: - state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) - state.sub_tasks = update_sub_tasks(state.sub_tasks, update_tasks_map) - # Also update camel_task.subtasks to remove deleted tasks - update_sub_tasks(state.camel_task.subtasks, update_tasks_map) - # Add new tasks (with empty id) to both camel_task and sub_tasks - new_tasks = add_sub_tasks(state.camel_task, item.data.task) - state.sub_tasks.extend(new_tasks) - # Save updated sub_tasks back to task_lock - state.task_lock.decompose_sub_tasks = state.sub_tasks - summary_task_content_local = getattr( - state.task_lock, "summary_task_content", state.summary_task_content - ) - return [ - to_sub_tasks(state.camel_task, summary_task_content_local) - ], LoopControl.NORMAL - - -def handle_add_task( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - # Check if this might be a misrouted second question - if state.camel_task is None and state.workforce is None: - logger.error( - "Cannot add task: both " - "camel_task and workforce " - "are None for project " - f"{state.options.project_id}" - ) - events.append( - sse_json( - "error", - { - "message": "Cannot add task: task not " - "initialized. Please start" - " a task first." - }, - ) - ) - return events, LoopControl.CONTINUE - - assert state.camel_task is not None - if state.workforce is None: - logger.error( - "Cannot add task: workforce" - " not initialized for " - "project " - f"{state.options.project_id}" - ) - events.append( - sse_json( - "error", - { - "message": "Workforce not initialized." - " Please start the task " - "first." - }, - ) - ) - return events, LoopControl.CONTINUE - - # Add task to the workforce queue - state.workforce.add_task(item.content, item.task_id, item.additional_info) - - returnData = { - "project_id": item.project_id, - "task_id": item.task_id or (len(state.camel_task.subtasks) + 1), - } - events.append(sse_json("add_task", returnData)) - return events, LoopControl.NORMAL - - -def handle_remove_task( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - if state.workforce is None: - logger.error( - "Cannot remove task: " - "workforce not initialized " - "for project " - f"{state.options.project_id}" - ) - events.append( - sse_json( - "error", - { - "message": "Workforce not initialized." - " Please start the task " - "first." - }, - ) - ) - return events, LoopControl.CONTINUE - - state.workforce.remove_task(item.task_id) - returnData = { - "project_id": item.project_id, - "task_id": item.task_id, - } - events.append(sse_json("remove_task", returnData)) - return events, LoopControl.NORMAL - - -def handle_skip_task( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - logger.info("=" * 80) - logger.info( - "🛑 [LIFECYCLE] SKIP_TASK action received (User clicked Stop button)", - extra={ - "project_id": state.options.project_id, - "item_project_id": item.project_id, - }, - ) - logger.info("=" * 80) - - # Prevent duplicate skip processing - if state.task_lock.status == Status.done: - logger.warning( - "[LIFECYCLE] SKIP_TASK " - "received but task already " - "marked as done. Ignoring." - ) - return events, LoopControl.CONTINUE - - wf_match = ( - state.workforce is not None - and item.project_id == state.options.project_id - ) - if wf_match: - logger.info( - "[LIFECYCLE] Workforce exists" - f" (id={id(state.workforce)}), " - "state=" - f"{state.workforce._state.name}, " - f"_running={state.workforce._running}" - ) - - # Stop workforce completely - logger.info("[LIFECYCLE] 🛑 Stopping workforce") - if state.workforce._running: - # Import correct BaseWorkforce from camel - from camel.societies.workforce.workforce import ( - Workforce as BaseWorkforce, - ) - - BaseWorkforce.stop(state.workforce) - logger.info( - "[LIFECYCLE] " - "BaseWorkforce.stop() " - "completed, state=" - f"{state.workforce._state.name}, " - f"_running={state.workforce._running}" - ) - - state.workforce.stop_gracefully() - logger.info("[LIFECYCLE] ✅ Workforce stopped gracefully") - - # Clear workforce to avoid state issues - state.workforce = None - logger.info( - "[LIFECYCLE] Workforce set " - "to None, will be recreated" - " on next question" - ) - else: - logger.warning( - "[LIFECYCLE] Cannot skip: workforce is None or project_id mismatch" - ) - - # Mark task as done and preserve context - state.task_lock.status = Status.done - end_message = "Task stoppedTask stopped by user" - state.task_lock.last_task_result = end_message - - # Add to conversation history - if state.camel_task is not None: - task_content: str = state.camel_task.content - if "=== CURRENT TASK ===" in task_content: - task_content = task_content.split("=== CURRENT TASK ===")[ - -1 - ].strip() - else: - task_content: str = f"Task {state.options.task_id}" - - state.task_lock.add_conversation( - "task_result", - { - "task_content": task_content, - "task_result": end_message, - "working_directory": get_working_directory( - state.options, state.task_lock - ), - }, - ) - - # Clear camel_task as well - state.camel_task = None - logger.info( - "[LIFECYCLE] Task marked as " - "done, workforce and " - "camel_task cleared, " - "ready for multi-turn" - ) - - events.append(sse_json("end", end_message)) - logger.info("[LIFECYCLE] Sent 'end' SSE event to frontend") - return events, LoopControl.NORMAL - - -def handle_start(state: StepSolveState, item) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - # Check conversation history length before starting task - is_exceeded, total_length = check_conversation_history_length( - state.task_lock - ) - if is_exceeded: - logger.error( - "Cannot start task: " - "conversation history too " - f"long ({total_length} chars)" - " for project " - f"{state.options.project_id}" - ) - ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." - ) - events.append( - sse_json( - "context_too_long", - { - "message": ctx_msg, - "current_length": total_length, - "max_length": 100000, - }, - ) - ) - return events, LoopControl.CONTINUE - - if state.workforce is not None: - if state.workforce._state.name == "PAUSED": - # Resume paused workforce - state.workforce.resume() - return events, LoopControl.CONTINUE - else: - return events, LoopControl.CONTINUE - - state.task_lock.status = Status.processing - if not state.sub_tasks: - state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) - task = asyncio.create_task(state.workforce.eigent_start(state.sub_tasks)) - state.task_lock.add_background_task(task) - return events, LoopControl.NORMAL - - -def handle_task_state( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - # Track completed task results for the end event - task_state = item.data.get("state", "unknown") - task_result = item.data.get("result", "") - - if task_state == "DONE" and task_result: - state.last_completed_task_result = task_result - - return [sse_json("task_state", item.data)], LoopControl.NORMAL - - -async def handle_end( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] END action " - "received for project " - f"{state.options.project_id}, " - f"task {state.options.task_id}" - ) - logger.info( - "[LIFECYCLE] camel_task " - f"exists: {state.camel_task is not None}" - ", current status: " - f"{state.task_lock.status}, workforce" - f" exists: {state.workforce is not None}" - ) - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Workforce state" - " at END: _state=" - f"{state.workforce._state.name}" - ", _running=" - f"{state.workforce._running}" - ) - logger.info("=" * 80) - - # Prevent duplicate end processing - if state.task_lock.status == Status.done: - logger.warning( - "[LIFECYCLE] END action " - "received but task already " - "marked as done. Ignoring " - "duplicate END action." - ) - return events, LoopControl.CONTINUE - - if state.camel_task is None: - logger.warning( - "END action received but " - "camel_task is None for " - "project " - f"{state.options.project_id}, " - f"task {state.options.task_id}. " - "This may indicate multiple " - "END actions or improper " - "task lifecycle management." - ) - # Use item data as final result if camel_task is None - final_result: str = str(item.data) if item.data else "Task completed" - else: - final_result: str = await get_task_result_with_optional_summary( - state.camel_task, state.options - ) - - state.task_lock.status = Status.done - state.task_lock.last_task_result = final_result - - # Handle task content - use fallback if camel_task is None - if state.camel_task is not None: - task_content: str = state.camel_task.content - if "=== CURRENT TASK ===" in task_content: - task_content = task_content.split("=== CURRENT TASK ===")[ - -1 - ].strip() - else: - task_content: str = f"Task {state.options.task_id}" - - state.task_lock.add_conversation( - "task_result", - { - "task_content": task_content, - "task_result": final_result, - "working_directory": get_working_directory( - state.options, state.task_lock - ), - }, - ) - - events.append(sse_json("end", final_result)) - - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Calling " - "workforce.stop_gracefully()" - " for project " - f"{state.options.project_id}, " - f"workforce id={id(state.workforce)}" - ) - state.workforce.stop_gracefully() - logger.info( - "[LIFECYCLE] Workforce " - "stopped gracefully for " - "project " - f"{state.options.project_id}" - ) - state.workforce = None - logger.info("[LIFECYCLE] Workforce set to None") - else: - logger.warning( - "[LIFECYCLE] Workforce " - "already None at end " - "action for project " - f"{state.options.project_id}" - ) - - state.camel_task = None - logger.info("[LIFECYCLE] camel_task set to None") - - if state.question_agent is not None: - state.question_agent.reset() - logger.info( - "[LIFECYCLE] question_agent" - " reset for project " - f"{state.options.project_id}" - ) - return events, LoopControl.NORMAL - - -def handle_supplement( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - if state.camel_task is None: - logger.warning( - "SUPPLEMENT action received " - "but camel_task is None for " - f"project {state.options.project_id}" - ) - events.append( - sse_json( - "error", - { - "message": "Cannot supplement task: " - "task not initialized. " - "Please start a task " - "first." - }, - ) - ) - return events, LoopControl.CONTINUE - else: - state.task_lock.status = Status.processing - state.camel_task.add_subtask( - Task( - content=item.data.question, - id=f"{state.camel_task.id}.{len(state.camel_task.subtasks)}", - ) - ) - if state.workforce is not None: - task = asyncio.create_task( - state.workforce.eigent_start(state.camel_task.subtasks) - ) - state.task_lock.add_background_task(task) - return events, LoopControl.NORMAL - - -def handle_budget_not_enough( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - if state.workforce is not None: - state.workforce.pause() - return [ - sse_json(Action.budget_not_enough, {"message": "budget not enouth"}) - ], LoopControl.NORMAL - - -async def handle_stop( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] STOP action received" - " for project " - f"{state.options.project_id}" - ) - logger.info("=" * 80) - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Workforce exists " - f"(id={id(state.workforce)}), " - f"_running={state.workforce._running}" - ", _state=" - f"{state.workforce._state.name}" - ) - if state.workforce._running: - logger.info( - "[LIFECYCLE] Calling workforce.stop() because _running=True" - ) - state.workforce.stop() - logger.info("[LIFECYCLE] workforce.stop() completed") - logger.info("[LIFECYCLE] Calling workforce.stop_gracefully()") - state.workforce.stop_gracefully() - logger.info( - "[LIFECYCLE] Workforce stopped" - " for project " - f"{state.options.project_id}" - ) - else: - logger.warning( - "[LIFECYCLE] Workforce is None" - " at stop action for project" - f" {state.options.project_id}" - ) - logger.info("[LIFECYCLE] Deleting task lock") - await delete_task_lock(state.task_lock.id) - logger.info("[LIFECYCLE] Task lock deleted, breaking out of loop") - return [], LoopControl.BREAK - - -def handle_pause(state: StepSolveState, item) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - if state.workforce is not None: - state.workforce.pause() - logger.info(f"Workforce paused for project {state.options.project_id}") - else: - logger.warning( - "Cannot pause: workforce is " - "None for project " - f"{state.options.project_id}" - ) - return [], LoopControl.NORMAL - - -def handle_resume( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - if state.workforce is not None: - state.workforce.resume() - logger.info( - f"Workforce resumed for project {state.options.project_id}" - ) - else: - logger.warning( - "Cannot resume: workforce " - "is None for project " - f"{state.options.project_id}" - ) - return [], LoopControl.NORMAL - - -async def handle_new_agent( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - if state.workforce is not None: - state.workforce.pause() - state.workforce.add_single_agent_worker( - format_agent_description(item), - await new_agent_model(item, state.options), - ) - state.workforce.resume() - return [], LoopControl.NORMAL - - -def handle_timeout( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] TIMEOUT action " - "received for project " - f"{state.options.project_id}, " - f"task {state.options.task_id}" - ) - logger.info(f"[LIFECYCLE] Timeout data: {item.data}") - logger.info("=" * 80) - - # Send timeout error to frontend - timeout_message = item.data.get("message", "Task execution timeout") - in_flight = item.data.get("in_flight_tasks", 0) - pending = item.data.get("pending_tasks", 0) - timeout_seconds = item.data.get("timeout_seconds", 0) - - return [ - sse_json( - "error", - { - "message": timeout_message, - "type": "timeout", - "details": { - "in_flight_tasks": in_flight, - "pending_tasks": pending, - "timeout_seconds": timeout_seconds, - }, - }, - ) - ], LoopControl.NORMAL - - -def handle_install_mcp( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - if state.mcp is None: - logger.error( - "Cannot install MCP: mcp " - "agent not initialized for " - "project " - f"{state.options.project_id}" - ) - events.append( - sse_json( - "error", - { - "message": "MCP agent not initialized." - " Please start a complex " - "task first." - }, - ) - ) - return events, LoopControl.CONTINUE - task = asyncio.create_task(install_mcp(state.mcp, item)) - state.task_lock.add_background_task(task) - return events, LoopControl.NORMAL diff --git a/backend/app/service/chat_service/router.py b/backend/app/service/chat_service/router.py index 1ef79c551..b1e5c8042 100644 --- a/backend/app/service/chat_service/router.py +++ b/backend/app/service/chat_service/router.py @@ -27,7 +27,6 @@ from app.agent.factory.task_summary import ( get_task_result_with_optional_summary, summary_task, - task_summary_agent, ) from app.model.chat import Status, sse_json from app.service.chat_service.decomposition import ( @@ -41,7 +40,6 @@ set_current_task_id, ) from app.utils.context import ( - build_context_for_workforce, build_conversation_context, check_conversation_history_length, ) @@ -150,7 +148,7 @@ async def handle_improve( logger.info("[NEW-QUESTION] Has attachments, treating as complex task") else: is_complex_task = await question_confirm( - state.question_agent, question, state.task_lock + question, state.options, state.task_lock ) logger.info( "[NEW-QUESTION] question_confirm" @@ -196,7 +194,7 @@ async def handle_improve_simple_task( ) try: - simple_resp = state.question_agent.step(simple_answer_prompt) + simple_resp = state.task_lock.question_agent.step(simple_answer_prompt) if simple_resp and simple_resp.msgs: answer_content = simple_resp.msgs[0].content else: @@ -375,7 +373,7 @@ async def handle_new_task_state( "[LIFECYCLE] Multi-turn: calling question_confirm for new task" ) is_multi_turn_complex = await question_confirm( - state.question_agent, new_task_content, state.task_lock + new_task_content, state.options, state.task_lock ) logger.info( "[LIFECYCLE] Multi-turn: " @@ -405,7 +403,7 @@ async def handle_new_task_state( ) try: - simple_resp = state.question_agent.step( + simple_resp = state.task_lock.question_agent.step( simple_answer_prompt ) if simple_resp and simple_resp.msgs: @@ -478,8 +476,8 @@ async def handle_new_task_state( logger.info( "[LIFECYCLE] Multi-turn: building context for workforce" ) - context_for_multi_turn = build_context_for_workforce( - state.task_lock, state.options + context_for_multi_turn = build_conversation_context( + state.task_lock ) on_stream_batch, on_stream_text, stream_state = ( @@ -503,9 +501,8 @@ async def handle_new_task_state( # Generate proper LLM summary for multi-turn tasks try: - multi_turn_summary_agent = task_summary_agent(state.options) new_summary_content = await asyncio.wait_for( - summary_task(multi_turn_summary_agent, state.camel_task), + summary_task(state.camel_task, state.options), timeout=10, ) logger.info( diff --git a/backend/app/utils/context.py b/backend/app/utils/context.py index 7c47d5f08..dd95a0b75 100644 --- a/backend/app/utils/context.py +++ b/backend/app/utils/context.py @@ -16,11 +16,10 @@ import logging -from app.model.chat import Chat from app.service.task import TaskLock from app.utils.file_utils import list_files -logger = logging.getLogger("chat_service") +logger = logging.getLogger(__name__) def format_task_context( @@ -227,16 +226,3 @@ def build_conversation_context( context += "\n" return context - - -def build_context_for_workforce( - task_lock: TaskLock, - options: Chat, - task_content: str | None = None, -) -> str: - """Build context information for workforce. - Instructs coordinator to actively load skills using list_skills/load_skill tools. - """ - return build_conversation_context( - task_lock, header="=== CONVERSATION HISTORY ===" - ) diff --git a/backend/tests/app/agent/factory/test_question_confirm.py b/backend/tests/app/agent/factory/test_question_confirm.py index 77a9bbeb4..d32c7cad3 100644 --- a/backend/tests/app/agent/factory/test_question_confirm.py +++ b/backend/tests/app/agent/factory/test_question_confirm.py @@ -16,36 +16,64 @@ import pytest -from app.agent.factory import question_confirm_agent +from app.agent.factory.question_confirm import question_confirm from app.model.chat import Chat +from app.service.task import TaskLock pytestmark = pytest.mark.unit +_mod = "app.agent.factory.question_confirm" -def test_question_confirm_agent_creation(sample_chat_data): - """Test question_confirm_agent creates specialized agent.""" + +@pytest.mark.asyncio +async def test_question_confirm_creates_agent(sample_chat_data): + """Test question_confirm lazily creates and caches the agent.""" options = Chat(**sample_chat_data) - # Setup task lock in the registry before calling agent function from app.service.task import task_locks - mock_task_lock = MagicMock() + mock_task_lock = MagicMock(spec=TaskLock) + mock_task_lock.conversation_history = [] + mock_task_lock.question_agent = None task_locks[options.task_id] = mock_task_lock - _mod = "app.agent.factory.question_confirm" + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="no")] + mock_agent.step.return_value = mock_resp + with ( - patch(f"{_mod}.agent_model") as mock_agent_model, + patch( + f"{_mod}._create_question_agent", return_value=mock_agent + ) as mock_create, patch("asyncio.create_task"), ): - mock_agent = MagicMock() - mock_agent_model.return_value = mock_agent + result = await question_confirm("hello", options, mock_task_lock) + + assert result is False + mock_create.assert_called_once_with(options) + # Agent should be cached on task_lock + assert mock_task_lock.question_agent is mock_agent + + +@pytest.mark.asyncio +async def test_question_confirm_reuses_cached_agent(sample_chat_data): + """Test question_confirm reuses cached agent from task_lock.""" + options = Chat(**sample_chat_data) + + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="yes")] + mock_agent.step.return_value = mock_resp - result = question_confirm_agent(options) + mock_task_lock = MagicMock(spec=TaskLock) + mock_task_lock.conversation_history = [] + mock_task_lock.question_agent = mock_agent # Already cached - assert result is mock_agent - mock_agent_model.assert_called_once() + with patch(f"{_mod}._create_question_agent") as mock_create: + result = await question_confirm( + "create a file", options, mock_task_lock + ) - # Check that it was called with question confirmation prompt - call_args = mock_agent_model.call_args - assert "question_confirm_agent" in call_args[0][0] # agent_name - assert "analyze a user's request" in call_args[0][1] # system_prompt + assert result is True + mock_create.assert_not_called() # Should NOT create a new agent diff --git a/backend/tests/app/agent/factory/test_task_summary.py b/backend/tests/app/agent/factory/test_task_summary.py index b6f058116..c28d4e087 100644 --- a/backend/tests/app/agent/factory/test_task_summary.py +++ b/backend/tests/app/agent/factory/test_task_summary.py @@ -15,37 +15,41 @@ from unittest.mock import MagicMock, patch import pytest +from camel.tasks import Task -from app.agent.factory import task_summary_agent +from app.agent.factory.task_summary import summary_task from app.model.chat import Chat pytestmark = pytest.mark.unit +_mod = "app.agent.factory.task_summary" -def test_task_summary_agent_creation(sample_chat_data): - """Test task_summary_agent creates specialized agent.""" + +@pytest.mark.asyncio +async def test_summary_task_creates_agent_and_summarizes(sample_chat_data): + """Test summary_task creates agent internally and generates summary.""" options = Chat(**sample_chat_data) - # Setup task lock in the registry before calling agent function from app.service.task import task_locks mock_task_lock = MagicMock() task_locks[options.task_id] = mock_task_lock - _mod = "app.agent.factory.task_summary" + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="Task Name|Summary of the task")] + mock_agent.step.return_value = mock_resp + + task = Task(content="Build a website", id="test_task") + with ( - patch(f"{_mod}.agent_model") as mock_agent_model, + patch( + f"{_mod}._create_summary_agent", return_value=mock_agent + ) as mock_create, patch("asyncio.create_task"), ): - mock_agent = MagicMock() - mock_agent_model.return_value = mock_agent - - result = task_summary_agent(options) - - assert result is mock_agent - mock_agent_model.assert_called_once() + result = await summary_task(task, options) - # Check that it was called with task summary prompt - call_args = mock_agent_model.call_args - assert "task_summary_agent" in call_args[0][0] # agent_name - assert "task assistant" in call_args[0][1].lower() # system_prompt + assert result == "Task Name|Summary of the task" + mock_create.assert_called_once_with(options) + mock_agent.step.assert_called_once() diff --git a/backend/tests/app/service/test_chat_service.py b/backend/tests/app/service/test_chat_service.py index 527536ebc..e9b61e044 100644 --- a/backend/tests/app/service/test_chat_service.py +++ b/backend/tests/app/service/test_chat_service.py @@ -21,7 +21,7 @@ from app.model.chat import Chat, NewAgent from app.service.chat_service import ( add_sub_tasks, - build_context_for_workforce, + build_conversation_context, collect_previous_task_context, construct_workforce, format_agent_description, @@ -310,12 +310,11 @@ def test_collect_previous_task_context_relative_paths(self, temp_dir): @pytest.mark.unit -class TestBuildContextForWorkforce: - """Test cases for build_context_for_workforce function.""" +class TestBuildConversationContext: + """Test cases for build_conversation_context function.""" - def test_build_context_for_workforce_basic(self, temp_dir): - """Test build_context_for_workforce with basic task lock and options.""" - # Create mock TaskLock + def test_build_conversation_context_basic(self, temp_dir): + """Test build_conversation_context with basic task lock.""" task_lock = MagicMock(spec=TaskLock) task_lock.conversation_history = [ { @@ -323,37 +322,25 @@ def test_build_context_for_workforce_basic(self, temp_dir): "content": "I will create a Python script for you", }, ] - task_lock.last_task_result = "Script created successfully" - task_lock.last_task_summary = "Python Script Creation" - # Create mock Chat options - options = MagicMock() - options.file_save_path.return_value = str(temp_dir) - - result = build_context_for_workforce(task_lock, options) + result = build_conversation_context(task_lock) # Should include conversation history header assert "=== CONVERSATION HISTORY ===" in result - # build_conversation_context only processes assistant and task_result roles assert "I will create a Python script for you" in result - def test_build_context_for_workforce_empty_history(self, temp_dir): - """Test build_context_for_workforce with empty conversation history.""" + def test_build_conversation_context_empty_history(self, temp_dir): + """Test build_conversation_context with empty conversation history.""" task_lock = MagicMock(spec=TaskLock) task_lock.conversation_history = [] - task_lock.last_task_result = "" - task_lock.last_task_summary = "" - - options = MagicMock() - options.file_save_path.return_value = str(temp_dir) - result = build_context_for_workforce(task_lock, options) + result = build_conversation_context(task_lock) # Should return empty string for no context assert result == "" - def test_build_context_for_workforce_task_result_role(self, temp_dir): - """Test build_context_for_workforce handles 'task_result' role.""" + def test_build_conversation_context_task_result_role(self, temp_dir): + """Test build_conversation_context handles 'task_result' role.""" task_lock = MagicMock(spec=TaskLock) task_lock.conversation_history = [ { @@ -365,20 +352,15 @@ def test_build_context_for_workforce_task_result_role(self, temp_dir): "content": "Task completed successfully", }, ] - task_lock.last_task_result = "Final result" - task_lock.last_task_summary = "Task summary" - options = MagicMock() - options.file_save_path.return_value = str(temp_dir) - - result = build_context_for_workforce(task_lock, options) + result = build_conversation_context(task_lock) # build_conversation_context appends string task_result content directly assert "Full task context from previous task" in result assert "Task completed successfully" in result - def test_build_context_for_workforce_with_last_task_result(self, temp_dir): - """Test build_context_for_workforce with assistant entries.""" + def test_build_conversation_context_with_assistant_entries(self, temp_dir): + """Test build_conversation_context with assistant entries.""" task_lock = MagicMock(spec=TaskLock) task_lock.conversation_history = [ { @@ -386,13 +368,8 @@ def test_build_context_for_workforce_with_last_task_result(self, temp_dir): "content": "Task completed with output.txt", }, ] - task_lock.last_task_result = "Task completed with output.txt" - task_lock.last_task_summary = "File creation task" - - options = MagicMock() - options.file_save_path.return_value = str(temp_dir) - result = build_context_for_workforce(task_lock, options) + result = build_conversation_context(task_lock) # Should include conversation history assert "=== CONVERSATION HISTORY ===" in result @@ -606,9 +583,13 @@ class TestChatServiceAgentOperations: async def test_question_confirm_simple_query(self, mock_camel_agent): """Test question_confirm with simple query returns False.""" mock_camel_agent.step.return_value.msgs[0].content = "no" - mock_camel_agent.chat_history = [] - result = await question_confirm(mock_camel_agent, "hello") + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [] + task_lock.question_agent = mock_camel_agent + + options = MagicMock() + result = await question_confirm("hello", options, task_lock) # Should return False for simple queries (no "yes" in response) assert result is False @@ -617,10 +598,16 @@ async def test_question_confirm_simple_query(self, mock_camel_agent): async def test_question_confirm_complex_task(self, mock_camel_agent): """Test question_confirm with complex task that should proceed.""" mock_camel_agent.step.return_value.msgs[0].content = "yes" - mock_camel_agent.chat_history = [] + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [] + task_lock.question_agent = mock_camel_agent + + options = MagicMock() result = await question_confirm( - mock_camel_agent, "Create a web application with authentication" + "Create a web application with authentication", + options, + task_lock, ) # Should return True for complex tasks @@ -638,7 +625,12 @@ async def test_summary_task(self, mock_camel_agent): id="web_app_task", ) - result = await summary_task(mock_camel_agent, task) + options = MagicMock() + with patch( + "app.agent.factory.task_summary._create_summary_agent", + return_value=mock_camel_agent, + ): + result = await summary_task(task, options) assert ( result @@ -776,9 +768,8 @@ async def test_step_solve_context_building_workflow( ) # Test the context building directly - # build_context_for_workforce now only calls build_conversation_context - # which only processes assistant and task_result roles - context = build_context_for_workforce(task_lock, options) + # build_conversation_context only processes assistant and task_result roles + context = build_conversation_context(task_lock) # Verify context includes conversation history header assert "=== CONVERSATION HISTORY ===" in context @@ -900,12 +891,6 @@ async def test_step_solve_basic_workflow( "app.service.chat_service.decomposition.construct_workforce", return_value=(mock_workforce, mock_mcp), ), - patch( - "app.service.chat_service._step_solve.question_confirm_agent" - ) as mock_question_agent, - patch( - "app.service.chat_service.router.task_summary_agent" - ) as mock_summary_agent, patch( "app.service.chat_service.router.question_confirm", return_value=True, @@ -915,8 +900,6 @@ async def test_step_solve_basic_workflow( return_value="Test Summary", ), ): - mock_question_agent.return_value = MagicMock() - mock_summary_agent.return_value = MagicMock() mock_workforce.eigent_make_sub_tasks.return_value = [] # Convert async generator to list @@ -1055,33 +1038,23 @@ def test_collect_previous_task_context_abspath_used(self, temp_dir): assert "=== CONTEXT FROM PREVIOUS TASK ===" in result assert "test.txt" in result - def test_build_context_for_workforce_missing_attributes(self, temp_dir): - """Test build_context_for_workforce handles missing attributes gracefully.""" - # Create task_lock without required attributes + def test_build_conversation_context_missing_attributes(self, temp_dir): + """Test build_conversation_context handles missing attributes gracefully.""" task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = None # Missing attribute - task_lock.last_task_result = None # Missing attribute - task_lock.last_task_summary = None # Missing attribute + task_lock.conversation_history = None - options = MagicMock() - options.file_save_path.return_value = str(temp_dir) - - result = build_context_for_workforce(task_lock, options) + result = build_conversation_context(task_lock) # Should handle missing attributes gracefully assert result == "" - def test_build_context_for_workforce_empty_conversation(self): - """Test build_context_for_workforce returns empty for empty conversation.""" + def test_build_conversation_context_empty_conversation(self): + """Test build_conversation_context returns empty for empty conversation.""" task_lock = MagicMock(spec=TaskLock) task_lock.conversation_history = [] - task_lock.last_task_result = "Test result" - task_lock.last_task_summary = "Test summary" - - options = MagicMock() # Should return empty string for empty conversation history - result = build_context_for_workforce(task_lock, options) + result = build_conversation_context(task_lock) assert result == "" def test_collect_previous_task_context_unicode_handling(self, temp_dir): @@ -1211,8 +1184,13 @@ async def test_question_confirm_agent_error(self, mock_camel_agent): """Test question_confirm when agent raises error.""" mock_camel_agent.step.side_effect = Exception("Agent error") + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [] + task_lock.question_agent = mock_camel_agent + + options = MagicMock() with pytest.raises(Exception, match="Agent error"): - await question_confirm(mock_camel_agent, "test question") + await question_confirm("test question", options, task_lock) @pytest.mark.asyncio async def test_summary_task_agent_error(self, mock_camel_agent): @@ -1220,9 +1198,16 @@ async def test_summary_task_agent_error(self, mock_camel_agent): mock_camel_agent.step.side_effect = Exception("Summary error") task = Task(content="Test task", id="test") + options = MagicMock() - with pytest.raises(Exception, match="Summary error"): - await summary_task(mock_camel_agent, task) + with ( + patch( + "app.agent.factory.task_summary._create_summary_agent", + return_value=mock_camel_agent, + ), + pytest.raises(Exception, match="Summary error"), + ): + await summary_task(task, options) @pytest.mark.asyncio async def test_construct_workforce_agent_creation_error( From 6dee86b8f95e4c359daef8bed952d29aea17dbd0 Mon Sep 17 00:00:00 2001 From: bytecii Date: Thu, 5 Mar 2026 09:31:20 -0800 Subject: [PATCH 3/4] Update --- backend/app/agent/factory/question_confirm.py | 24 + backend/app/agent/factory/workforce_agents.py | 58 +- backend/app/agent/prompt.py | 44 + backend/app/controller/chat_controller.py | 2 +- backend/app/service/chat_service/__init__.py | 46 - .../app/service/chat_service/decomposition.py | 15 +- backend/app/service/chat_service/handlers.py | 601 ++------ backend/app/service/chat_service/lifecycle.py | 82 +- .../service/chat_service/question_router.py | 343 +++++ backend/app/service/chat_service/router.py | 570 -------- .../{_step_solve.py => step_solve.py} | 37 +- backend/app/service/chat_service/types.py | 46 + .../agent/factory/test_question_confirm.py | 65 + .../app/agent/factory/test_task_summary.py | 105 ++ .../agent/factory/test_workforce_agents.py | 83 ++ .../app/service/chat_service/__init__.py | 13 + .../service/chat_service/test_lifecycle.py | 394 +++++ .../service/chat_service/test_step_solve.py | 209 +++ .../tests/app/service/test_chat_service.py | 1301 ----------------- backend/tests/app/utils/test_context.py | 400 +++++ 20 files changed, 1922 insertions(+), 2516 deletions(-) create mode 100644 backend/app/service/chat_service/question_router.py delete mode 100644 backend/app/service/chat_service/router.py rename backend/app/service/chat_service/{_step_solve.py => step_solve.py} (89%) create mode 100644 backend/app/service/chat_service/types.py create mode 100644 backend/tests/app/agent/factory/test_workforce_agents.py create mode 100644 backend/tests/app/service/chat_service/__init__.py create mode 100644 backend/tests/app/service/chat_service/test_lifecycle.py create mode 100644 backend/tests/app/service/chat_service/test_step_solve.py delete mode 100644 backend/tests/app/service/test_chat_service.py create mode 100644 backend/tests/app/utils/test_context.py diff --git a/backend/app/agent/factory/question_confirm.py b/backend/app/agent/factory/question_confirm.py index aa876e83f..982771dbb 100644 --- a/backend/app/agent/factory/question_confirm.py +++ b/backend/app/agent/factory/question_confirm.py @@ -21,6 +21,7 @@ from app.agent.prompt import ( QUESTION_CONFIRM_PROMPT, QUESTION_CONFIRM_SYS_PROMPT, + SIMPLE_ANSWER_PROMPT, ) from app.agent.utils import NOW_STR from app.model.chat import Chat @@ -97,3 +98,26 @@ async def question_confirm( except Exception as e: logger.error(f"Error in question_confirm: {e}") raise + + +async def simple_answer( + question: str, options: Chat, task_lock: TaskLock +) -> str: + """Generate a direct answer to a simple question using the cached agent.""" + if ( + not hasattr(task_lock, "question_agent") + or task_lock.question_agent is None + ): + task_lock.question_agent = _create_question_agent(options) + + context_prompt = build_conversation_context( + task_lock, header="=== Previous Conversation ===" + ) + prompt = SIMPLE_ANSWER_PROMPT.format( + context_prompt=context_prompt, user_query=question + ) + + resp = task_lock.question_agent.step(prompt) + if resp and resp.msgs and resp.msgs[0].content: + return resp.msgs[0].content + return "I understand your question, but I'm having trouble generating a response right now." diff --git a/backend/app/agent/factory/workforce_agents.py b/backend/app/agent/factory/workforce_agents.py index ce8dad429..b2997c9f7 100644 --- a/backend/app/agent/factory/workforce_agents.py +++ b/backend/app/agent/factory/workforce_agents.py @@ -23,6 +23,7 @@ from camel.toolkits import ToolkitMessageIntegration from app.agent.agent_model import agent_model +from app.agent.prompt import AGENT_ENVIRONMENT_PROMPT from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.skill_toolkit import SkillToolkit @@ -33,6 +34,15 @@ from app.agent.listen_chat_agent import ListenChatAgent +def _env_prompt(working_directory: str) -> str: + return AGENT_ENVIRONMENT_PROMPT.format( + platform_system=platform.system(), + platform_machine=platform.machine(), + working_directory=working_directory, + current_date=datetime.date.today(), + ) + + def create_coordinator_and_task_agents( options: Chat, working_directory: str ) -> list[ListenChatAgent]: @@ -64,32 +74,14 @@ def create_coordinator_and_task_agents( ], ) for key, prompt in { - Agents.coordinator_agent: f""" -You are a helpful coordinator. -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. - """, - Agents.task_agent: f""" -You are a helpful task planner. -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. - """, + Agents.coordinator_agent: ( + "You are a helpful coordinator.\n" + + _env_prompt(working_directory) + ), + Agents.task_agent: ( + "You are a helpful task planner.\n" + + _env_prompt(working_directory) + ), }.items() ] @@ -100,19 +92,7 @@ def create_new_worker_agent( """Create new worker agent (sync, runs in thread pool).""" return agent_model( Agents.new_worker_agent, - f""" - You are a helpful assistant. -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. - """, + "You are a helpful assistant.\n" + _env_prompt(working_directory), options, [ *HumanToolkit.get_can_use_tools( diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 9fbef2e40..357f33fd2 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -219,6 +219,43 @@ TASK_SUMMARY_SYS_PROMPT = """\ You are a helpful task assistant that can help users summarize the content of their tasks""" +DEVELOPER_WORKER_DESCRIPTION = ( + "Developer Agent: A master-level coding assistant with a powerful " + "terminal. It can write and execute code, manage files, automate " + "desktop tasks, and deploy web applications to solve complex " + "technical challenges." +) + +BROWSER_WORKER_DESCRIPTION = ( + "Browser Agent: Can search the web, extract webpage content, " + "simulate browser actions, and provide relevant information to " + "solve the given task." +) + +DOCUMENT_WORKER_DESCRIPTION = ( + "Document Agent: A document processing assistant skilled in creating " + "and modifying a wide range of file formats. It can generate " + "text-based files/reports (Markdown, JSON, YAML, HTML), " + "office documents (Word, PDF), presentations (PowerPoint), and " + "data files (Excel, CSV)." +) + +MULTI_MODAL_WORKER_DESCRIPTION = ( + "Multi-Modal Agent: A specialist in media processing. It can " + "analyze images and audio, transcribe speech, download videos, and " + "generate new images from text prompts." +) + +AGENT_ENVIRONMENT_PROMPT = """\ +- You are now working in system {platform_system} with architecture \ +{platform_machine} at working directory `{working_directory}`. All local \ +file operations must occur here, but you can access files from any place \ +in the file system. For all file system operations, you MUST use absolute \ +paths to ensure precision and avoid ambiguity. +The current date is {current_date}. For any date-related tasks, you MUST \ +use this as the current date. +""" + QUESTION_CONFIRM_SYS_PROMPT = """\ You are a highly capable agent. Your primary function is to analyze a user's \ request and determine the appropriate course of action. The current date is \ @@ -754,6 +791,13 @@ Summary: """ +SIMPLE_ANSWER_PROMPT = """\ +{context_prompt}\ +User Query: {user_query} + +Provide a direct, helpful answer to this simple question. +""" + DEFAULT_SUMMARY_PROMPT = ( "After completing the task, please generate" " a summary of the entire task completion. " diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 87e42a70a..f3a489925 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -35,7 +35,7 @@ SupplementChat, sse_json, ) -from app.service.chat_service import step_solve +from app.service.chat_service.step_solve import step_solve from app.service.task import ( Action, ActionAddTaskData, diff --git a/backend/app/service/chat_service/__init__.py b/backend/app/service/chat_service/__init__.py index 388417f59..fa7455a0c 100644 --- a/backend/app/service/chat_service/__init__.py +++ b/backend/app/service/chat_service/__init__.py @@ -11,49 +11,3 @@ # See the License for the specific language governing permissions and # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - -"""Chat service package — re-exports for backward compatibility.""" - -from app.agent.factory.question_confirm import question_confirm -from app.agent.factory.task_summary import ( - get_task_result_with_optional_summary, - summary_subtasks_result, - summary_task, -) -from app.service.chat_service._step_solve import step_solve -from app.service.chat_service.lifecycle import ( - add_sub_tasks, - construct_workforce, - format_agent_description, - install_mcp, - new_agent_model, - to_sub_tasks, - tree_sub_tasks, - update_sub_tasks, -) -from app.utils.context import ( - build_conversation_context, - check_conversation_history_length, - collect_previous_task_context, - format_task_context, -) - -__all__ = [ - "step_solve", - "build_conversation_context", - "check_conversation_history_length", - "collect_previous_task_context", - "format_task_context", - "get_task_result_with_optional_summary", - "question_confirm", - "summary_subtasks_result", - "summary_task", - "add_sub_tasks", - "construct_workforce", - "format_agent_description", - "install_mcp", - "new_agent_model", - "to_sub_tasks", - "tree_sub_tasks", - "update_sub_tasks", -] diff --git a/backend/app/service/chat_service/decomposition.py b/backend/app/service/chat_service/decomposition.py index a20581c7c..36e7b4d67 100644 --- a/backend/app/service/chat_service/decomposition.py +++ b/backend/app/service/chat_service/decomposition.py @@ -12,14 +12,12 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Task decomposition and streaming callbacks.""" - from __future__ import annotations import asyncio import logging from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from camel.tasks import Task @@ -31,6 +29,7 @@ new_agent_model, tree_sub_tasks, ) +from app.service.chat_service.types import LoopControl, StepSolveState from app.service.task import ( ActionDecomposeProgressData, ActionDecomposeTextData, @@ -38,13 +37,7 @@ ) from app.utils.context import build_conversation_context -if TYPE_CHECKING: - from app.service.chat_service._step_solve import ( - LoopControl, - StepSolveState, - ) - -logger = logging.getLogger("chat_service") +logger = logging.getLogger(__name__) def create_stream_callbacks( @@ -208,8 +201,6 @@ async def handle_improve_complex_task( ) -> tuple[list[str], LoopControl]: """Handle complex task: create workforce, setup camel_task, kick off decomposition.""" - from app.service.chat_service._step_solve import LoopControl - events = [] logger.info( "[NEW-QUESTION] Complex task, creating workforce and decomposing" diff --git a/backend/app/service/chat_service/handlers.py b/backend/app/service/chat_service/handlers.py index 3620ec852..f1640bf5b 100644 --- a/backend/app/service/chat_service/handlers.py +++ b/backend/app/service/chat_service/handlers.py @@ -12,13 +12,10 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Event handlers for the step_solve dispatch loop.""" - from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING from camel.tasks import Task @@ -34,25 +31,65 @@ to_sub_tasks, update_sub_tasks, ) +from app.service.chat_service.types import LoopControl, StepSolveState from app.service.task import Action, delete_task_lock from app.utils.context import check_conversation_history_length from app.utils.file_utils import get_working_directory -if TYPE_CHECKING: - from app.service.chat_service._step_solve import ( - LoopControl, - StepSolveState, - ) - logger = logging.getLogger(__name__) -def handle_passthrough_event(item) -> str | None: - """Handle simple passthrough events that just forward data as SSE. +def _sse_error(message: str) -> str: + return sse_json("error", {"message": message}) + + +def _stop_workforce(state: StepSolveState, *, force: bool = False) -> None: + """Stop and clear the workforce. *force* uses BaseWorkforce.stop() + (needed by skip_task to bypass the subclass override).""" + if state.workforce is None: + logger.info("Workforce is None, nothing to stop") + return + if state.workforce._running: + if force: + from camel.societies.workforce.workforce import ( + Workforce as BaseWorkforce, + ) + + BaseWorkforce.stop(state.workforce) + else: + state.workforce.stop() + state.workforce.stop_gracefully() + logger.info(f"Workforce stopped for project {state.options.project_id}") + + +def _extract_task_content(state: StepSolveState) -> str: + """Get the user-facing task content string from camel_task.""" + if state.camel_task is not None: + content: str = state.camel_task.content + if "=== CURRENT TASK ===" in content: + content = content.split("=== CURRENT TASK ===")[-1].strip() + return content + return f"Task {state.options.task_id}" + + +def _finalize_task(state: StepSolveState, result: str) -> None: + """Mark the task done, save conversation history, clear camel_task.""" + state.task_lock.status = Status.done + state.task_lock.last_task_result = result + state.task_lock.add_conversation( + "task_result", + { + "task_content": _extract_task_content(state), + "task_result": result, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + state.camel_task = None + - Returns: - SSE JSON string if the action is a passthrough, None otherwise. - """ +def handle_passthrough_event(item) -> str | None: if item.action == Action.create_agent: return sse_json("create_agent", item.data) elif item.action == Action.activate_agent: @@ -68,30 +105,21 @@ def handle_passthrough_event(item) -> str | None: elif item.action == Action.write_file: return sse_json( "write_file", - { - "file_path": item.data, - "process_task_id": item.process_task_id, - }, + {"file_path": item.data, "process_task_id": item.process_task_id}, ) elif item.action == Action.ask: return sse_json("ask", item.data) elif item.action == Action.notice: return sse_json( "notice", - { - "notice": item.data, - "process_task_id": item.process_task_id, - }, + {"notice": item.data, "process_task_id": item.process_task_id}, ) elif item.action == Action.search_mcp: return sse_json("search_mcp", item.data) elif item.action == Action.terminal: return sse_json( "terminal", - { - "output": item.data, - "process_task_id": item.process_task_id, - }, + {"output": item.data, "process_task_id": item.process_task_id}, ) elif item.action == Action.decompose_text: return sse_json("decompose_text", item.data) @@ -101,39 +129,17 @@ def handle_passthrough_event(item) -> str | None: def handle_disconnect(state: StepSolveState) -> tuple[list[str], LoopControl]: - """Handle client disconnect. Returns BREAK to exit the main loop.""" - from app.service.chat_service._step_solve import LoopControl - - # This is called after checking request.is_disconnected() - # The actual async disconnect check is done in _step_solve.py - logger.warning("=" * 80) logger.warning( - "[LIFECYCLE] CLIENT DISCONNECTED " - f"for project {state.options.project_id}" + f"Client disconnected for project {state.options.project_id}" ) - logger.warning("=" * 80) - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Stopping workforce " - "due to client disconnect, " - "workforce._running=" - f"{state.workforce._running}" - ) - if state.workforce._running: - state.workforce.stop() - state.workforce.stop_gracefully() - logger.info("[LIFECYCLE] Workforce stopped after client disconnect") - else: - logger.info("[LIFECYCLE] Workforce is None, no need to stop") + _stop_workforce(state) state.task_lock.status = Status.done return [], LoopControl.BREAK async def handle_disconnect_cleanup(state: StepSolveState) -> None: - """Async cleanup after disconnect (delete task lock).""" try: await delete_task_lock(state.task_lock.id) - logger.info("[LIFECYCLE] Task lock deleted after client disconnect") except Exception as e: logger.error(f"Error deleting task lock on disconnect: {e}") @@ -141,20 +147,14 @@ async def handle_disconnect_cleanup(state: StepSolveState) -> None: def handle_update_task( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - assert state.camel_task is not None update_tasks_map = {item.id: item for item in item.data.task} - # Use stored decomposition results if available if not state.sub_tasks: state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) state.sub_tasks = update_sub_tasks(state.sub_tasks, update_tasks_map) - # Also update camel_task.subtasks to remove deleted tasks update_sub_tasks(state.camel_task.subtasks, update_tasks_map) - # Add new tasks (with empty id) to both camel_task and sub_tasks new_tasks = add_sub_tasks(state.camel_task, item.data.task) state.sub_tasks.extend(new_tasks) - # Save updated sub_tasks back to task_lock state.task_lock.decompose_sub_tasks = state.sub_tasks summary_task_content_local = getattr( state.task_lock, "summary_task_content", state.summary_task_content @@ -167,435 +167,199 @@ def handle_update_task( def handle_add_task( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - # Check if this might be a misrouted second question if state.camel_task is None and state.workforce is None: logger.error( - "Cannot add task: both " - "camel_task and workforce " - "are None for project " - f"{state.options.project_id}" + f"Cannot add task: not initialized for {state.options.project_id}" ) - events.append( - sse_json( - "error", - { - "message": "Cannot add task: task not " - "initialized. Please start" - " a task first." - }, + return [ + _sse_error( + "Cannot add task: task not initialized. Please start a task first." ) - ) - return events, LoopControl.CONTINUE + ], LoopControl.CONTINUE assert state.camel_task is not None if state.workforce is None: logger.error( - "Cannot add task: workforce" - " not initialized for " - "project " - f"{state.options.project_id}" + f"Cannot add task: workforce not initialized for {state.options.project_id}" ) - events.append( - sse_json( - "error", - { - "message": "Workforce not initialized." - " Please start the task " - "first." - }, + return [ + _sse_error( + "Workforce not initialized. Please start the task first." ) - ) - return events, LoopControl.CONTINUE + ], LoopControl.CONTINUE - # Add task to the workforce queue state.workforce.add_task(item.content, item.task_id, item.additional_info) - - returnData = { - "project_id": item.project_id, - "task_id": item.task_id or (len(state.camel_task.subtasks) + 1), - } - events.append(sse_json("add_task", returnData)) - return events, LoopControl.NORMAL + return [ + sse_json( + "add_task", + { + "project_id": item.project_id, + "task_id": item.task_id + or (len(state.camel_task.subtasks) + 1), + }, + ) + ], LoopControl.NORMAL def handle_remove_task( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] if state.workforce is None: logger.error( - "Cannot remove task: " - "workforce not initialized " - "for project " - f"{state.options.project_id}" + f"Cannot remove task: workforce not initialized for {state.options.project_id}" ) - events.append( - sse_json( - "error", - { - "message": "Workforce not initialized." - " Please start the task " - "first." - }, + return [ + _sse_error( + "Workforce not initialized. Please start the task first." ) - ) - return events, LoopControl.CONTINUE + ], LoopControl.CONTINUE state.workforce.remove_task(item.task_id) - returnData = { - "project_id": item.project_id, - "task_id": item.task_id, - } - events.append(sse_json("remove_task", returnData)) - return events, LoopControl.NORMAL + return [ + sse_json( + "remove_task", + {"project_id": item.project_id, "task_id": item.task_id}, + ) + ], LoopControl.NORMAL def handle_skip_task( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl + logger.info(f"SKIP_TASK received for project {state.options.project_id}") - events = [] - logger.info("=" * 80) - logger.info( - "🛑 [LIFECYCLE] SKIP_TASK action received (User clicked Stop button)", - extra={ - "project_id": state.options.project_id, - "item_project_id": item.project_id, - }, - ) - logger.info("=" * 80) - - # Prevent duplicate skip processing if state.task_lock.status == Status.done: - logger.warning( - "[LIFECYCLE] SKIP_TASK " - "received but task already " - "marked as done. Ignoring." - ) - return events, LoopControl.CONTINUE + logger.warning("SKIP_TASK ignored: task already done") + return [], LoopControl.CONTINUE - wf_match = ( + if ( state.workforce is not None and item.project_id == state.options.project_id - ) - if wf_match: - logger.info( - "[LIFECYCLE] Workforce exists" - f" (id={id(state.workforce)}), " - "state=" - f"{state.workforce._state.name}, " - f"_running={state.workforce._running}" - ) - - # Stop workforce completely - logger.info("[LIFECYCLE] 🛑 Stopping workforce") - if state.workforce._running: - # Import correct BaseWorkforce from camel - from camel.societies.workforce.workforce import ( - Workforce as BaseWorkforce, - ) - - BaseWorkforce.stop(state.workforce) - logger.info( - "[LIFECYCLE] " - "BaseWorkforce.stop() " - "completed, state=" - f"{state.workforce._state.name}, " - f"_running={state.workforce._running}" - ) - - state.workforce.stop_gracefully() - logger.info("[LIFECYCLE] ✅ Workforce stopped gracefully") - - # Clear workforce to avoid state issues + ): + _stop_workforce(state, force=True) state.workforce = None - logger.info( - "[LIFECYCLE] Workforce set " - "to None, will be recreated" - " on next question" - ) - else: - logger.warning( - "[LIFECYCLE] Cannot skip: workforce is None or project_id mismatch" - ) - # Mark task as done and preserve context - state.task_lock.status = Status.done end_message = "Task stoppedTask stopped by user" - state.task_lock.last_task_result = end_message - - # Add to conversation history - if state.camel_task is not None: - task_content: str = state.camel_task.content - if "=== CURRENT TASK ===" in task_content: - task_content = task_content.split("=== CURRENT TASK ===")[ - -1 - ].strip() - else: - task_content: str = f"Task {state.options.task_id}" - - state.task_lock.add_conversation( - "task_result", - { - "task_content": task_content, - "task_result": end_message, - "working_directory": get_working_directory( - state.options, state.task_lock - ), - }, - ) - - # Clear camel_task as well - state.camel_task = None - logger.info( - "[LIFECYCLE] Task marked as " - "done, workforce and " - "camel_task cleared, " - "ready for multi-turn" - ) - - events.append(sse_json("end", end_message)) - logger.info("[LIFECYCLE] Sent 'end' SSE event to frontend") - return events, LoopControl.NORMAL + _finalize_task(state, end_message) + logger.info("Task stopped, ready for multi-turn") + return [sse_json("end", end_message)], LoopControl.NORMAL def handle_start(state: StepSolveState, item) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - # Check conversation history length before starting task is_exceeded, total_length = check_conversation_history_length( state.task_lock ) if is_exceeded: logger.error( - "Cannot start task: " - "conversation history too " - f"long ({total_length} chars)" - " for project " - f"{state.options.project_id}" - ) - ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." + f"Conversation history too long ({total_length} chars) for {state.options.project_id}" ) - events.append( + return [ sse_json( "context_too_long", { - "message": ctx_msg, + "message": "The conversation history is too long. Please create a new project to continue.", "current_length": total_length, "max_length": 100000, }, ) - ) - return events, LoopControl.CONTINUE + ], LoopControl.CONTINUE if state.workforce is not None: if state.workforce._state.name == "PAUSED": - # Resume paused workforce state.workforce.resume() - return events, LoopControl.CONTINUE + return [], LoopControl.CONTINUE else: - return events, LoopControl.CONTINUE + return [], LoopControl.CONTINUE state.task_lock.status = Status.processing if not state.sub_tasks: state.sub_tasks = getattr(state.task_lock, "decompose_sub_tasks", []) task = asyncio.create_task(state.workforce.eigent_start(state.sub_tasks)) state.task_lock.add_background_task(task) - return events, LoopControl.NORMAL + return [], LoopControl.NORMAL def handle_task_state( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - # Track completed task results for the end event task_state = item.data.get("state", "unknown") task_result = item.data.get("result", "") - if task_state == "DONE" and task_result: state.last_completed_task_result = task_result - return [sse_json("task_state", item.data)], LoopControl.NORMAL async def handle_end( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] - logger.info("=" * 80) logger.info( - "[LIFECYCLE] END action " - "received for project " - f"{state.options.project_id}, " - f"task {state.options.task_id}" + f"END received for project {state.options.project_id}, task {state.options.task_id}" ) - logger.info( - "[LIFECYCLE] camel_task " - f"exists: {state.camel_task is not None}" - ", current status: " - f"{state.task_lock.status}, workforce" - f" exists: {state.workforce is not None}" - ) - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Workforce state" - " at END: _state=" - f"{state.workforce._state.name}" - ", _running=" - f"{state.workforce._running}" - ) - logger.info("=" * 80) - # Prevent duplicate end processing if state.task_lock.status == Status.done: - logger.warning( - "[LIFECYCLE] END action " - "received but task already " - "marked as done. Ignoring " - "duplicate END action." - ) - return events, LoopControl.CONTINUE + logger.warning("END ignored: task already done") + return [], LoopControl.CONTINUE if state.camel_task is None: logger.warning( - "END action received but " - "camel_task is None for " - "project " - f"{state.options.project_id}, " - f"task {state.options.task_id}. " - "This may indicate multiple " - "END actions or improper " - "task lifecycle management." + f"END with camel_task=None for {state.options.project_id}" ) - # Use item data as final result if camel_task is None final_result: str = str(item.data) if item.data else "Task completed" else: final_result: str = await get_task_result_with_optional_summary( state.camel_task, state.options ) - state.task_lock.status = Status.done - state.task_lock.last_task_result = final_result - - # Handle task content - use fallback if camel_task is None - if state.camel_task is not None: - task_content: str = state.camel_task.content - if "=== CURRENT TASK ===" in task_content: - task_content = task_content.split("=== CURRENT TASK ===")[ - -1 - ].strip() - else: - task_content: str = f"Task {state.options.task_id}" - - state.task_lock.add_conversation( - "task_result", - { - "task_content": task_content, - "task_result": final_result, - "working_directory": get_working_directory( - state.options, state.task_lock - ), - }, - ) - - events.append(sse_json("end", final_result)) + _finalize_task(state, final_result) if state.workforce is not None: - logger.info( - "[LIFECYCLE] Calling " - "workforce.stop_gracefully()" - " for project " - f"{state.options.project_id}, " - f"workforce id={id(state.workforce)}" - ) state.workforce.stop_gracefully() + state.workforce = None logger.info( - "[LIFECYCLE] Workforce " - "stopped gracefully for " - "project " - f"{state.options.project_id}" + f"Workforce stopped for project {state.options.project_id}" ) - state.workforce = None - logger.info("[LIFECYCLE] Workforce set to None") else: logger.warning( - "[LIFECYCLE] Workforce " - "already None at end " - "action for project " - f"{state.options.project_id}" + f"Workforce already None at end for {state.options.project_id}" ) - state.camel_task = None - logger.info("[LIFECYCLE] camel_task set to None") - if state.task_lock.question_agent is not None: state.task_lock.question_agent.reset() - logger.info( - "[LIFECYCLE] question_agent" - " reset for project " - f"{state.options.project_id}" - ) - return events, LoopControl.NORMAL + + return [sse_json("end", final_result)], LoopControl.NORMAL def handle_supplement( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] if state.camel_task is None: logger.warning( - "SUPPLEMENT action received " - "but camel_task is None for " - f"project {state.options.project_id}" + f"SUPPLEMENT with camel_task=None for {state.options.project_id}" ) - events.append( - sse_json( - "error", - { - "message": "Cannot supplement task: " - "task not initialized. " - "Please start a task " - "first." - }, + return [ + _sse_error( + "Cannot supplement task: task not initialized. Please start a task first." ) + ], LoopControl.CONTINUE + + state.task_lock.status = Status.processing + state.camel_task.add_subtask( + Task( + content=item.data.question, + id=f"{state.camel_task.id}.{len(state.camel_task.subtasks)}", ) - return events, LoopControl.CONTINUE - else: - state.task_lock.status = Status.processing - state.camel_task.add_subtask( - Task( - content=item.data.question, - id=f"{state.camel_task.id}.{len(state.camel_task.subtasks)}", - ) + ) + if state.workforce is not None: + task = asyncio.create_task( + state.workforce.eigent_start(state.camel_task.subtasks) ) - if state.workforce is not None: - task = asyncio.create_task( - state.workforce.eigent_start(state.camel_task.subtasks) - ) - state.task_lock.add_background_task(task) - return events, LoopControl.NORMAL + state.task_lock.add_background_task(task) + return [], LoopControl.NORMAL def handle_budget_not_enough( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - if state.workforce is not None: state.workforce.pause() return [ @@ -606,59 +370,19 @@ def handle_budget_not_enough( async def handle_stop( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] STOP action received" - " for project " - f"{state.options.project_id}" - ) - logger.info("=" * 80) - if state.workforce is not None: - logger.info( - "[LIFECYCLE] Workforce exists " - f"(id={id(state.workforce)}), " - f"_running={state.workforce._running}" - ", _state=" - f"{state.workforce._state.name}" - ) - if state.workforce._running: - logger.info( - "[LIFECYCLE] Calling workforce.stop() because _running=True" - ) - state.workforce.stop() - logger.info("[LIFECYCLE] workforce.stop() completed") - logger.info("[LIFECYCLE] Calling workforce.stop_gracefully()") - state.workforce.stop_gracefully() - logger.info( - "[LIFECYCLE] Workforce stopped" - " for project " - f"{state.options.project_id}" - ) - else: - logger.warning( - "[LIFECYCLE] Workforce is None" - " at stop action for project" - f" {state.options.project_id}" - ) - logger.info("[LIFECYCLE] Deleting task lock") + logger.info(f"STOP received for project {state.options.project_id}") + _stop_workforce(state) await delete_task_lock(state.task_lock.id) - logger.info("[LIFECYCLE] Task lock deleted, breaking out of loop") return [], LoopControl.BREAK def handle_pause(state: StepSolveState, item) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - if state.workforce is not None: state.workforce.pause() logger.info(f"Workforce paused for project {state.options.project_id}") else: logger.warning( - "Cannot pause: workforce is " - "None for project " - f"{state.options.project_id}" + f"Cannot pause: workforce is None for {state.options.project_id}" ) return [], LoopControl.NORMAL @@ -666,8 +390,6 @@ def handle_pause(state: StepSolveState, item) -> tuple[list[str], LoopControl]: def handle_resume( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - if state.workforce is not None: state.workforce.resume() logger.info( @@ -675,9 +397,7 @@ def handle_resume( ) else: logger.warning( - "Cannot resume: workforce " - "is None for project " - f"{state.options.project_id}" + f"Cannot resume: workforce is None for {state.options.project_id}" ) return [], LoopControl.NORMAL @@ -685,8 +405,6 @@ def handle_resume( async def handle_new_agent( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - if state.workforce is not None: state.workforce.pause() state.workforce.add_single_agent_worker( @@ -700,34 +418,17 @@ async def handle_new_agent( def handle_timeout( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] TIMEOUT action " - "received for project " - f"{state.options.project_id}, " - f"task {state.options.task_id}" - ) - logger.info(f"[LIFECYCLE] Timeout data: {item.data}") - logger.info("=" * 80) - - # Send timeout error to frontend - timeout_message = item.data.get("message", "Task execution timeout") - in_flight = item.data.get("in_flight_tasks", 0) - pending = item.data.get("pending_tasks", 0) - timeout_seconds = item.data.get("timeout_seconds", 0) - + logger.info(f"TIMEOUT for project {state.options.project_id}: {item.data}") return [ sse_json( "error", { - "message": timeout_message, + "message": item.data.get("message", "Task execution timeout"), "type": "timeout", "details": { - "in_flight_tasks": in_flight, - "pending_tasks": pending, - "timeout_seconds": timeout_seconds, + "in_flight_tasks": item.data.get("in_flight_tasks", 0), + "pending_tasks": item.data.get("pending_tasks", 0), + "timeout_seconds": item.data.get("timeout_seconds", 0), }, }, ) @@ -737,27 +438,15 @@ def handle_timeout( def handle_install_mcp( state: StepSolveState, item ) -> tuple[list[str], LoopControl]: - from app.service.chat_service._step_solve import LoopControl - - events = [] if state.mcp is None: logger.error( - "Cannot install MCP: mcp " - "agent not initialized for " - "project " - f"{state.options.project_id}" + f"Cannot install MCP: agent not initialized for {state.options.project_id}" ) - events.append( - sse_json( - "error", - { - "message": "MCP agent not initialized." - " Please start a complex " - "task first." - }, + return [ + _sse_error( + "MCP agent not initialized. Please start a complex task first." ) - ) - return events, LoopControl.CONTINUE + ], LoopControl.CONTINUE task = asyncio.create_task(install_mcp(state.mcp, item)) state.task_lock.add_background_task(task) - return events, LoopControl.NORMAL + return [], LoopControl.NORMAL diff --git a/backend/app/service/chat_service/lifecycle.py b/backend/app/service/chat_service/lifecycle.py index 3545ded8e..a77d9af65 100644 --- a/backend/app/service/chat_service/lifecycle.py +++ b/backend/app/service/chat_service/lifecycle.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Workforce setup, task CRUD helpers, and agent construction.""" - from __future__ import annotations import asyncio @@ -39,6 +37,13 @@ create_new_worker_agent, ) from app.agent.listen_chat_agent import ListenChatAgent +from app.agent.prompt import ( + AGENT_ENVIRONMENT_PROMPT, + BROWSER_WORKER_DESCRIPTION, + DEVELOPER_WORKER_DESCRIPTION, + DOCUMENT_WORKER_DESCRIPTION, + MULTI_MODAL_WORKER_DESCRIPTION, +) from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.tools import get_mcp_tools, get_toolkits from app.model.chat import Chat, NewAgent, TaskContent, sse_json @@ -51,11 +56,6 @@ logger = logging.getLogger(__name__) -# ============================================================================ -# Standalone helper functions (moved as-is from chat_service.py) -# ============================================================================ - - async def install_mcp( mcp: ListenChatAgent, install_mcp: ActionInstallMcpData, @@ -175,7 +175,9 @@ def format_agent_description(agent_data: NewAgent | ActionNewAgent) -> str: return " ".join(description_parts) -async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): +async def new_agent_model( + data: NewAgent | ActionNewAgent, options: Chat +) -> ListenChatAgent: logger.info( "Creating new agent", extra={ @@ -192,7 +194,6 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): tools = [*await get_toolkits(data.tools, data.name, options.project_id)] for item in data.tools: tool_names.append(titleize(item)) - # Always include terminal_toolkit with proper working directory terminal_toolkit = TerminalToolkit( options.project_id, agent_name=data.name, @@ -211,21 +212,17 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): logger.info( f"Agent {data.name} created with {len(tools)} tools: {tool_names}" ) - # Enhanced system message with platform information - enhanced_description = f"""{data.description} -- You are now working in system {platform.system()} with architecture -{platform.machine()} at working directory \ -`{working_directory}`. All local file operations \ -must occur here, but you can access files from any \ -place in the file system. For all file system \ -operations, you MUST use absolute paths to ensure \ -precision and avoid ambiguity. -The current date is {datetime.date.today()}. \ -For any date-related tasks, you MUST use this as \ -the current date. -""" - - # Pass per-agent custom model config if available + enhanced_description = ( + data.description + + "\n" + + AGENT_ENVIRONMENT_PROMPT.format( + platform_system=platform.system(), + platform_machine=platform.machine(), + working_directory=working_directory, + current_date=datetime.date.today(), + ) + ) + custom_model_config = getattr(data, "custom_model_config", None) return agent_model( data.name, @@ -258,10 +255,6 @@ async def construct_workforce( working_directory = get_working_directory(options) - # ======================================================================== - # Execute all agent creations in PARALLEL - # ======================================================================== - try: # asyncio.gather runs all coroutines concurrently # asyncio.to_thread runs sync functions in @@ -304,10 +297,6 @@ async def construct_workforce( coordinator_agent, task_agent = coord_task_agents - # ======================================================================== - # Create Workforce instance and add workers (must be sequential) - # ======================================================================== - try: model_platform_enum = ModelPlatformType(options.model_platform.lower()) except (ValueError, AttributeError): @@ -333,32 +322,11 @@ async def construct_workforce( # Register workforce metrics callback workforce._callbacks.append(workforce_metrics) + workforce.add_single_agent_worker(DEVELOPER_WORKER_DESCRIPTION, developer) + workforce.add_single_agent_worker(BROWSER_WORKER_DESCRIPTION, searcher) + workforce.add_single_agent_worker(DOCUMENT_WORKER_DESCRIPTION, documenter) workforce.add_single_agent_worker( - "Developer Agent: A master-level coding assistant with a powerful " - "terminal. It can write and execute code, manage files, automate " - "desktop tasks, and deploy web applications to solve complex " - "technical challenges.", - developer, - ) - workforce.add_single_agent_worker( - "Browser Agent: Can search the web, extract webpage content, " - "simulate browser actions, and provide relevant information to " - "solve the given task.", - searcher, - ) - workforce.add_single_agent_worker( - "Document Agent: A document processing assistant skilled in creating " - "and modifying a wide range of file formats. It can generate " - "text-based files/reports (Markdown, JSON, YAML, HTML), " - "office documents (Word, PDF), presentations (PowerPoint), and " - "data files (Excel, CSV).", - documenter, - ) - workforce.add_single_agent_worker( - "Multi-Modal Agent: A specialist in media processing. It can " - "analyze images and audio, transcribe speech, download videos, and " - "generate new images from text prompts.", - multi_modaler, + MULTI_MODAL_WORKER_DESCRIPTION, multi_modaler ) return workforce, mcp diff --git a/backend/app/service/chat_service/question_router.py b/backend/app/service/chat_service/question_router.py new file mode 100644 index 000000000..6a970f0d2 --- /dev/null +++ b/backend/app/service/chat_service/question_router.py @@ -0,0 +1,343 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from __future__ import annotations + +import asyncio +import logging +import time +from pathlib import Path + +from camel.tasks import Task + +from app.agent.factory.question_confirm import question_confirm, simple_answer +from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + summary_task, +) +from app.model.chat import Status, sse_json +from app.service.chat_service.decomposition import ( + create_stream_callbacks, + handle_improve_complex_task, +) +from app.service.chat_service.handlers import _extract_task_content +from app.service.chat_service.lifecycle import tree_sub_tasks +from app.service.chat_service.types import LoopControl, StepSolveState +from app.service.task import ( + ActionDecomposeProgressData, + ActionImproveData, + set_current_task_id, +) +from app.utils.context import ( + build_conversation_context, + check_conversation_history_length, +) +from app.utils.file_utils import get_working_directory + +logger = logging.getLogger(__name__) + + +def _check_context_too_long(state: StepSolveState) -> str | None: + """Return a context_too_long SSE event if history is exceeded, else None.""" + is_exceeded, total_length = check_conversation_history_length( + state.task_lock + ) + if not is_exceeded: + return None + logger.error( + f"Conversation history too long ({total_length} chars) for {state.options.project_id}" + ) + return sse_json( + "context_too_long", + { + "message": "The conversation history is too long. Please create a new project to continue.", + "current_length": total_length, + "max_length": 100000, + }, + ) + + +def _summary_fallback(task_content: str) -> str: + """Build a fallback summary string when LLM summary fails.""" + if len(task_content) > 100: + return f"Follow-up Task|{task_content[:97]}..." + return f"Follow-up Task|{task_content}" + + +async def _generate_simple_answer(state: StepSolveState, question: str) -> str: + """Generate a simple answer, returning a fallback on error.""" + try: + return await simple_answer(question, state.options, state.task_lock) + except Exception as e: + logger.error(f"Error generating simple answer: {e}") + return "I encountered an error while processing your question." + + +async def handle_improve( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + """Top-level router for Action.improve: extract question, + check history, classify simple vs complex, delegate.""" + + events: list[str] = [] + + if state.start_event_loop is True: + question = state.options.question + attaches_to_use = state.options.attaches + logger.info(f"Action.improve (initial): '{question[:100]}...'") + state.start_event_loop = False + else: + assert isinstance(item, ActionImproveData) + question = item.data.question + attaches_to_use = ( + item.data.attaches + if item.data.attaches + else state.options.attaches + ) + logger.info(f"Action.improve (follow-up): '{question[:100]}...'") + + ctx_event = _check_context_too_long(state) + if ctx_event is not None: + return [ctx_event], LoopControl.CONTINUE + + # Determine task complexity + if len(attaches_to_use) > 0: + is_complex_task = True + logger.info("Has attachments, treating as complex task") + else: + is_complex_task = await question_confirm( + question, state.options, state.task_lock + ) + logger.info(f"question_confirm result: is_complex={is_complex_task}") + + if not is_complex_task: + simple_events, control = await handle_improve_simple_task( + state, question + ) + events.extend(simple_events) + return events, control + else: + complex_events, control = await handle_improve_complex_task( + state, item, question, attaches_to_use + ) + events.extend(complex_events) + return events, control + + +async def handle_improve_simple_task( + state: StepSolveState, question: str +) -> tuple[list[str], LoopControl]: + """Handle simple question: direct LLM answer, folder cleanup.""" + + answer_content = await _generate_simple_answer(state, question) + + state.task_lock.add_conversation("assistant", answer_content) + events = [ + sse_json( + "wait_confirm", + {"content": answer_content, "question": question}, + ) + ] + + # Clean up empty folder if it was created for this task + if ( + hasattr(state.task_lock, "new_folder_path") + and state.task_lock.new_folder_path + ): + try: + folder_path = Path(state.task_lock.new_folder_path) + if folder_path.exists() and folder_path.is_dir(): + if not any(folder_path.iterdir()): + folder_path.rmdir() + logger.info(f"Cleaned up empty folder: {folder_path}") + project_folder = folder_path.parent + if project_folder.exists() and not any( + project_folder.iterdir() + ): + project_folder.rmdir() + logger.info( + f"Cleaned up empty project folder: {project_folder}" + ) + state.task_lock.new_folder_path = None + except Exception as e: + logger.error(f"Error cleaning up folder: {e}") + + return events, LoopControl.CONTINUE + + +async def handle_new_task_state( + state: StepSolveState, item +) -> tuple[list[str], LoopControl]: + """Handle Action.new_task_state: multi-turn question handling.""" + + events: list[str] = [] + new_task_id = item.data.get("task_id", "unknown") + logger.info( + f"NEW_TASK_STATE (multi-turn): task_id={new_task_id}, " + f"state={item.data.get('state', 'unknown')}" + ) + + if state.camel_task is None: + logger.error( + f"NEW_TASK_STATE with camel_task=None for {state.options.project_id}" + ) + events.append( + sse_json( + "error", + { + "message": "Cannot process new task state: current task not initialized." + }, + ) + ) + return events, LoopControl.CONTINUE + + old_task_content = _extract_task_content(state) + old_task_result = await get_task_result_with_optional_summary( + state.camel_task, state.options + ) + + state.task_lock.add_conversation( + "task_result", + { + "task_content": old_task_content, + "task_result": old_task_result, + "working_directory": get_working_directory( + state.options, state.task_lock + ), + }, + ) + + new_task_content = item.data.get("content", "") + + if new_task_content: + task_id = item.data.get("task_id", f"{int(time.time() * 1000)}-multi") + new_camel_task = Task(content=new_task_content, id=task_id) + if ( + hasattr(state.camel_task, "additional_info") + and state.camel_task.additional_info + ): + new_camel_task.additional_info = state.camel_task.additional_info + state.camel_task = new_camel_task + + events.append(sse_json("end", old_task_result)) + events.append(sse_json("new_task_state", item.data)) + events.append( + sse_json("remove_task", {"task_id": item.data.get("task_id")}) + ) + + if state.workforce is not None and new_task_content: + state.task_lock.status = Status.confirming + state.workforce.pause() + logger.info( + f"Multi-turn: workforce paused, state={state.workforce._state.name}" + ) + + try: + is_multi_turn_complex = await question_confirm( + new_task_content, state.options, state.task_lock + ) + logger.info( + f"Multi-turn question_confirm: is_complex={is_multi_turn_complex}" + ) + + if not is_multi_turn_complex: + answer_content = await _generate_simple_answer( + state, new_task_content + ) + state.task_lock.add_conversation("assistant", answer_content) + events.append( + sse_json( + "wait_confirm", + { + "content": answer_content, + "question": new_task_content, + }, + ) + ) + state.workforce.resume() + return events, LoopControl.CONTINUE + + set_current_task_id(state.options.project_id, task_id) + events.append( + sse_json("confirmed", {"question": new_task_content}) + ) + state.task_lock.status = Status.confirmed + + context_for_multi_turn = build_conversation_context( + state.task_lock + ) + + on_stream_batch, on_stream_text, stream_state = ( + create_stream_callbacks(state) + ) + + wf = state.workforce + new_sub_tasks = await wf.handle_decompose_append_task( + state.camel_task, + reset=False, + coordinator_context=context_for_multi_turn, + on_stream_batch=on_stream_batch, + on_stream_text=on_stream_text, + ) + if stream_state["subtasks"]: + new_sub_tasks = stream_state["subtasks"] + logger.info( + f"Multi-turn: task decomposed into {len(new_sub_tasks)} subtasks" + ) + + try: + new_summary_content = await asyncio.wait_for( + summary_task(state.camel_task, state.options), + timeout=10, + ) + logger.info( + f"Generated LLM summary for multi-turn task ({state.options.project_id})" + ) + except (TimeoutError, Exception) as e: + level = "warning" if isinstance(e, TimeoutError) else "error" + getattr(logger, level)( + f"Multi-turn summary_task failed: {e}", + extra={"project_id": state.options.project_id}, + ) + new_summary_content = _summary_fallback(new_task_content) + + final_payload = { + "project_id": state.options.project_id, + "task_id": state.options.task_id, + "sub_tasks": tree_sub_tasks(state.camel_task.subtasks), + "delta_sub_tasks": tree_sub_tasks(new_sub_tasks), + "is_final": True, + "summary_task": new_summary_content, + } + await state.task_lock.put_queue( + ActionDecomposeProgressData(data=final_payload) + ) + + state.sub_tasks = new_sub_tasks + state.summary_task_content = new_summary_content + + except Exception as e: + logger.error( + f"Failed to process multi-turn task: {e}", exc_info=True + ) + events.append( + sse_json("error", {"message": f"Failed to process task: {e}"}) + ) + else: + if state.workforce is None: + logger.warning("Multi-turn: workforce is None") + if not new_task_content: + logger.warning("Multi-turn: no new task content provided") + + return events, LoopControl.NORMAL diff --git a/backend/app/service/chat_service/router.py b/backend/app/service/chat_service/router.py deleted file mode 100644 index b1e5c8042..000000000 --- a/backend/app/service/chat_service/router.py +++ /dev/null @@ -1,570 +0,0 @@ -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - -"""Question classification and routing: simple vs complex.""" - -from __future__ import annotations - -import asyncio -import logging -from pathlib import Path -from typing import TYPE_CHECKING - -from camel.tasks import Task - -from app.agent.factory.question_confirm import question_confirm -from app.agent.factory.task_summary import ( - get_task_result_with_optional_summary, - summary_task, -) -from app.model.chat import Status, sse_json -from app.service.chat_service.decomposition import ( - create_stream_callbacks, - handle_improve_complex_task, -) -from app.service.chat_service.lifecycle import tree_sub_tasks -from app.service.task import ( - ActionDecomposeProgressData, - ActionImproveData, - set_current_task_id, -) -from app.utils.context import ( - build_conversation_context, - check_conversation_history_length, -) -from app.utils.file_utils import get_working_directory - -if TYPE_CHECKING: - from app.service.chat_service._step_solve import ( - LoopControl, - StepSolveState, - ) - -logger = logging.getLogger("chat_service") - - -async def handle_improve( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - """Top-level router for Action.improve: extract question, - check history, classify simple vs complex, delegate.""" - from app.service.chat_service._step_solve import LoopControl - - events = [] - logger.info("=" * 80) - logger.info( - "[NEW-QUESTION] Action.improve received or start_event_loop", - extra={ - "project_id": state.options.project_id, - "start_event_loop": state.start_event_loop, - }, - ) - wf_state = ( - "None" - if state.workforce is None - else f"exists(id={id(state.workforce)})" - ) - logger.info( - f"[NEW-QUESTION] Current workforce state: workforce={wf_state}" - ) - ct_state = ( - "None" - if state.camel_task is None - else f"exists(id={state.camel_task.id})" - ) - logger.info( - f"[NEW-QUESTION] Current camel_task state: camel_task={ct_state}" - ) - logger.info("=" * 80) - - if state.start_event_loop is True: - question = state.options.question - attaches_to_use = state.options.attaches - logger.info( - "[NEW-QUESTION] Initial question" - " from options.question: " - f"'{question[:100]}...'" - ) - state.start_event_loop = False - else: - assert isinstance(item, ActionImproveData) - question = item.data.question - attaches_to_use = ( - item.data.attaches - if item.data.attaches - else state.options.attaches - ) - logger.info( - "[NEW-QUESTION] Follow-up " - "question from " - "ActionImproveData: " - f"'{question[:100]}...'" - ) - - is_exceeded, total_length = check_conversation_history_length( - state.task_lock - ) - if is_exceeded: - logger.error( - "Conversation history too long", - extra={ - "project_id": state.options.project_id, - "current_length": total_length, - "max_length": 100000, - }, - ) - ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." - ) - events.append( - sse_json( - "context_too_long", - { - "message": ctx_msg, - "current_length": total_length, - "max_length": 100000, - }, - ) - ) - return events, LoopControl.CONTINUE - - # Determine task complexity - is_complex_task: bool - if len(attaches_to_use) > 0: - is_complex_task = True - logger.info("[NEW-QUESTION] Has attachments, treating as complex task") - else: - is_complex_task = await question_confirm( - question, state.options, state.task_lock - ) - logger.info( - "[NEW-QUESTION] question_confirm" - " result: is_complex=" - f"{is_complex_task}" - ) - - if not is_complex_task: - simple_events, control = await handle_improve_simple_task( - state, question - ) - events.extend(simple_events) - return events, control - else: - complex_events, control = await handle_improve_complex_task( - state, item, question, attaches_to_use - ) - events.extend(complex_events) - return events, control - - -async def handle_improve_simple_task( - state: StepSolveState, question: str -) -> tuple[list[str], LoopControl]: - """Handle simple question: direct LLM answer, folder cleanup.""" - from app.service.chat_service._step_solve import LoopControl - - events = [] - logger.info( - "[NEW-QUESTION] Simple question" - ", providing direct answer " - "without workforce" - ) - conv_ctx = build_conversation_context( - state.task_lock, header="=== Previous Conversation ===" - ) - simple_answer_prompt = ( - f"{conv_ctx}" - f"User Query: {question}\n\n" - "Provide a direct, helpful " - "answer to this simple " - "question." - ) - - try: - simple_resp = state.task_lock.question_agent.step(simple_answer_prompt) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[0].content - else: - answer_content = ( - "I understand your " - "question, but I'm " - "having trouble " - "generating a response " - "right now." - ) - - state.task_lock.add_conversation("assistant", answer_content) - - events.append( - sse_json( - "wait_confirm", - {"content": answer_content, "question": question}, - ) - ) - except Exception as e: - logger.error(f"Error generating simple answer: {e}") - events.append( - sse_json( - "wait_confirm", - { - "content": "I encountered an error" - " while processing " - "your question.", - "question": question, - }, - ) - ) - - # Clean up empty folder if it was created for this task - if ( - hasattr(state.task_lock, "new_folder_path") - and state.task_lock.new_folder_path - ): - try: - folder_path = Path(state.task_lock.new_folder_path) - if folder_path.exists() and folder_path.is_dir(): - # Check if folder is empty - if not any(folder_path.iterdir()): - folder_path.rmdir() - logger.info(f"Cleaned up empty folder: {folder_path}") - # Also clean up parent project folder if empty - project_folder = folder_path.parent - if project_folder.exists() and not any( - project_folder.iterdir() - ): - project_folder.rmdir() - logger.info( - "Cleaned up " - "empty project" - " folder: " - f"{project_folder}" - ) - else: - logger.info(f"Folder not empty, keeping: {folder_path}") - # Reset the folder path - state.task_lock.new_folder_path = None - except Exception as e: - logger.error(f"Error cleaning up folder: {e}") - - return events, LoopControl.CONTINUE - - -async def handle_new_task_state( - state: StepSolveState, item -) -> tuple[list[str], LoopControl]: - """Handle Action.new_task_state: multi-turn question handling.""" - from app.service.chat_service._step_solve import LoopControl - - events = [] - logger.info("=" * 80) - logger.info( - "[LIFECYCLE] NEW_TASK_STATE action received (Multi-turn)", - extra={"project_id": state.options.project_id}, - ) - logger.info("=" * 80) - - # Log new task state details - new_task_id = item.data.get("task_id", "unknown") - new_task_state = item.data.get("state", "unknown") - logger.info( - "[LIFECYCLE] New task details" - f": task_id={new_task_id}, " - f"state={new_task_state}" - ) - - if state.camel_task is None: - logger.error( - "NEW_TASK_STATE action " - "received but camel_task " - "is None for project " - f"{state.options.project_id}, " - f"task {new_task_id}" - ) - events.append( - sse_json( - "error", - { - "message": "Cannot process new task " - "state: current task not " - "initialized." - }, - ) - ) - return events, LoopControl.CONTINUE - - old_task_content: str = state.camel_task.content - old_task_result: str = await get_task_result_with_optional_summary( - state.camel_task, state.options - ) - - old_task_content_clean: str = old_task_content - if "=== CURRENT TASK ===" in old_task_content_clean: - old_task_content_clean = old_task_content_clean.split( - "=== CURRENT TASK ===" - )[-1].strip() - - state.task_lock.add_conversation( - "task_result", - { - "task_content": old_task_content_clean, - "task_result": old_task_result, - "working_directory": get_working_directory( - state.options, state.task_lock - ), - }, - ) - - new_task_content = item.data.get("content", "") - - if new_task_content: - import time - - task_id = item.data.get("task_id", f"{int(time.time() * 1000)}-multi") - new_camel_task = Task(content=new_task_content, id=task_id) - if ( - hasattr(state.camel_task, "additional_info") - and state.camel_task.additional_info - ): - new_camel_task.additional_info = state.camel_task.additional_info - state.camel_task = new_camel_task - - # Now trigger end of previous task using stored result - events.append(sse_json("end", old_task_result)) - - # Always yield new_task_state first - this is not optional - events.append(sse_json("new_task_state", item.data)) - # Trigger Queue Removal - events.append( - sse_json("remove_task", {"task_id": item.data.get("task_id")}) - ) - - # Then handle multi-turn processing - if state.workforce is not None and new_task_content: - logger.info( - "[LIFECYCLE] Multi-turn: " - "workforce exists " - f"(id={id(state.workforce)}), " - "pausing for question " - "confirmation" - ) - state.task_lock.status = Status.confirming - state.workforce.pause() - logger.info( - "[LIFECYCLE] Multi-turn: " - "workforce paused, state=" - f"{state.workforce._state.name}" - ) - - try: - logger.info( - "[LIFECYCLE] Multi-turn: calling question_confirm for new task" - ) - is_multi_turn_complex = await question_confirm( - new_task_content, state.options, state.task_lock - ) - logger.info( - "[LIFECYCLE] Multi-turn: " - "question_confirm result:" - " is_complex=" - f"{is_multi_turn_complex}" - ) - - if not is_multi_turn_complex: - logger.info( - "[LIFECYCLE] Multi-turn: " - "task is simple, providing" - " direct answer without " - "workforce" - ) - conv_ctx = build_conversation_context( - state.task_lock, - header="=== Previous Conversation ===", - ) - simple_answer_prompt = ( - f"{conv_ctx}" - "User Query: " - f"{new_task_content}" - "\n\nProvide a direct, " - "helpful answer to this " - "simple question." - ) - - try: - simple_resp = state.task_lock.question_agent.step( - simple_answer_prompt - ) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[0].content - else: - answer_content = ( - "I understand your " - "question, but I'm " - "having trouble " - "generating a response" - " right now." - ) - - state.task_lock.add_conversation( - "assistant", answer_content - ) - - events.append( - sse_json( - "wait_confirm", - { - "content": answer_content, - "question": new_task_content, - }, - ) - ) - except Exception as e: - logger.error( - f"Error generating simple answer in multi-turn: {e}" - ) - events.append( - sse_json( - "wait_confirm", - { - "content": "I encountered an error " - "while processing your " - "question.", - "question": new_task_content, - }, - ) - ) - - logger.info( - "[LIFECYCLE] Multi-turn: " - "simple answer provided, " - "resuming workforce" - ) - state.workforce.resume() - logger.info( - "[LIFECYCLE] Multi-turn: " - "workforce resumed, " - "continuing to next " - "iteration" - ) - return events, LoopControl.CONTINUE - - # Update the sync_step with new task_id - logger.info( - "[LIFECYCLE] Multi-turn: " - "task is complex, setting " - f"new task_id={task_id}" - ) - set_current_task_id(state.options.project_id, task_id) - - events.append( - sse_json("confirmed", {"question": new_task_content}) - ) - state.task_lock.status = Status.confirmed - - logger.info( - "[LIFECYCLE] Multi-turn: building context for workforce" - ) - context_for_multi_turn = build_conversation_context( - state.task_lock - ) - - on_stream_batch, on_stream_text, stream_state = ( - create_stream_callbacks(state) - ) - - wf = state.workforce - new_sub_tasks = await wf.handle_decompose_append_task( - state.camel_task, - reset=False, - coordinator_context=context_for_multi_turn, - on_stream_batch=on_stream_batch, - on_stream_text=on_stream_text, - ) - if stream_state["subtasks"]: - new_sub_tasks = stream_state["subtasks"] - n = len(new_sub_tasks) - logger.info( - f"[LIFECYCLE] Multi-turn: task decomposed into {n} subtasks" - ) - - # Generate proper LLM summary for multi-turn tasks - try: - new_summary_content = await asyncio.wait_for( - summary_task(state.camel_task, state.options), - timeout=10, - ) - logger.info( - "Generated LLM summary for multi-turn task", - extra={"project_id": state.options.project_id}, - ) - except TimeoutError: - logger.warning( - "Multi-turn summary_task timeout", - extra={ - "project_id": state.options.project_id, - "task_id": task_id, - }, - ) - task_content_for_summary = new_task_content - tc = task_content_for_summary - if len(tc) > 100: - new_summary_content = f"Follow-up Task|{tc[:97]}..." - else: - new_summary_content = f"Follow-up Task|{tc}" - except Exception as e: - logger.error(f"Error generating multi-turn task summary: {e}") - task_content_for_summary = new_task_content - tc = task_content_for_summary - if len(tc) > 100: - new_summary_content = f"Follow-up Task|{tc[:97]}..." - else: - new_summary_content = f"Follow-up Task|{tc}" - - # Emit final subtasks once when decomposition is complete - final_payload = { - "project_id": state.options.project_id, - "task_id": state.options.task_id, - "sub_tasks": tree_sub_tasks(state.camel_task.subtasks), - "delta_sub_tasks": tree_sub_tasks(new_sub_tasks), - "is_final": True, - "summary_task": new_summary_content, - } - await state.task_lock.put_queue( - ActionDecomposeProgressData(data=final_payload) - ) - - # Update the context with new task data - state.sub_tasks = new_sub_tasks - state.summary_task_content = new_summary_content - - except Exception as e: - import traceback - - logger.error(f"[TRACE] Traceback: {traceback.format_exc()}") - events.append( - sse_json( - "error", - {"message": f"Failed to process task: {str(e)}"}, - ) - ) - else: - if state.workforce is None: - logger.warning( - "[TRACE] Workforce is None - this might be the issue" - ) - if not new_task_content: - logger.warning("[TRACE] No new task content provided") - - return events, LoopControl.NORMAL diff --git a/backend/app/service/chat_service/_step_solve.py b/backend/app/service/chat_service/step_solve.py similarity index 89% rename from backend/app/service/chat_service/_step_solve.py rename to backend/app/service/chat_service/step_solve.py index 853488dc0..aaa9d2d80 100644 --- a/backend/app/service/chat_service/_step_solve.py +++ b/backend/app/service/chat_service/step_solve.py @@ -12,18 +12,12 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Main step_solve dispatcher and shared state.""" - import asyncio import logging -from dataclasses import dataclass, field -from enum import Enum from camel.models import ModelProcessingError -from camel.tasks import Task from fastapi import Request -from app.agent.listen_chat_agent import ListenChatAgent from app.model.chat import Chat, sse_json from app.service.chat_service.handlers import ( handle_add_task, @@ -45,40 +39,15 @@ handle_timeout, handle_update_task, ) -from app.service.chat_service.router import ( +from app.service.chat_service.question_router import ( handle_improve, handle_new_task_state, ) +from app.service.chat_service.types import LoopControl, StepSolveState from app.service.task import Action, TaskLock from app.utils.server.sync_step import sync_step -from app.utils.workforce import Workforce - -logger = logging.getLogger("chat_service") - - -class LoopControl(Enum): - """Control flow for the main step_solve loop.""" - - NORMAL = "normal" - CONTINUE = "continue" - BREAK = "break" - - -@dataclass -class StepSolveState: - """Mutable state bag for step_solve's main loop.""" - options: Chat - request: Request - task_lock: TaskLock - workforce: Workforce | None = None - camel_task: Task | None = None - mcp: ListenChatAgent | None = None - sub_tasks: list[Task] = field(default_factory=list) - summary_task_content: str = "" - last_completed_task_result: str = "" - start_event_loop: bool = True - event_loop: asyncio.AbstractEventLoop | None = None +logger = logging.getLogger(__name__) def _initialize_state(state: StepSolveState) -> None: diff --git a/backend/app/service/chat_service/types.py b/backend/app/service/chat_service/types.py new file mode 100644 index 000000000..65e21870f --- /dev/null +++ b/backend/app/service/chat_service/types.py @@ -0,0 +1,46 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import asyncio +from dataclasses import dataclass, field +from enum import Enum + +from camel.tasks import Task +from fastapi import Request + +from app.agent.listen_chat_agent import ListenChatAgent +from app.model.chat import Chat +from app.service.task import TaskLock +from app.utils.workforce import Workforce + + +class LoopControl(Enum): + NORMAL = "normal" + CONTINUE = "continue" + BREAK = "break" + + +@dataclass +class StepSolveState: + options: Chat + request: Request + task_lock: TaskLock + workforce: Workforce | None = None + camel_task: Task | None = None + mcp: ListenChatAgent | None = None + sub_tasks: list[Task] = field(default_factory=list) + summary_task_content: str = "" + last_completed_task_result: str = "" + start_event_loop: bool = True + event_loop: asyncio.AbstractEventLoop | None = None diff --git a/backend/tests/app/agent/factory/test_question_confirm.py b/backend/tests/app/agent/factory/test_question_confirm.py index d32c7cad3..74b4268e8 100644 --- a/backend/tests/app/agent/factory/test_question_confirm.py +++ b/backend/tests/app/agent/factory/test_question_confirm.py @@ -77,3 +77,68 @@ async def test_question_confirm_reuses_cached_agent(sample_chat_data): assert result is True mock_create.assert_not_called() # Should NOT create a new agent + + +@pytest.mark.asyncio +async def test_simple_answer_returns_content(sample_chat_data): + options = Chat(**sample_chat_data) + + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="The answer is 42.")] + mock_agent.step.return_value = mock_resp + + mock_task_lock = MagicMock(spec=TaskLock) + mock_task_lock.conversation_history = [] + mock_task_lock.question_agent = mock_agent + + from app.agent.factory.question_confirm import simple_answer + + result = await simple_answer( + "What is the answer?", options, mock_task_lock + ) + assert result == "The answer is 42." + mock_agent.step.assert_called_once() + + +@pytest.mark.asyncio +async def test_simple_answer_creates_agent_when_none(sample_chat_data): + options = Chat(**sample_chat_data) + + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="Created and answered.")] + mock_agent.step.return_value = mock_resp + + mock_task_lock = MagicMock(spec=TaskLock) + mock_task_lock.conversation_history = [] + mock_task_lock.question_agent = None + + from app.agent.factory.question_confirm import simple_answer + + with patch( + f"{_mod}._create_question_agent", return_value=mock_agent + ) as mock_create: + result = await simple_answer("Hello?", options, mock_task_lock) + assert result == "Created and answered." + mock_create.assert_called_once_with(options) + assert mock_task_lock.question_agent is mock_agent + + +@pytest.mark.asyncio +async def test_simple_answer_fallback_on_empty_response(sample_chat_data): + options = Chat(**sample_chat_data) + + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="")] + mock_agent.step.return_value = mock_resp + + mock_task_lock = MagicMock(spec=TaskLock) + mock_task_lock.conversation_history = [] + mock_task_lock.question_agent = mock_agent + + from app.agent.factory.question_confirm import simple_answer + + result = await simple_answer("Hello?", options, mock_task_lock) + assert "trouble generating" in result diff --git a/backend/tests/app/agent/factory/test_task_summary.py b/backend/tests/app/agent/factory/test_task_summary.py index c28d4e087..d84efd159 100644 --- a/backend/tests/app/agent/factory/test_task_summary.py +++ b/backend/tests/app/agent/factory/test_task_summary.py @@ -53,3 +53,108 @@ async def test_summary_task_creates_agent_and_summarizes(sample_chat_data): assert result == "Task Name|Summary of the task" mock_create.assert_called_once_with(options) mock_agent.step.assert_called_once() + + +@pytest.mark.asyncio +async def test_summary_subtasks_result(sample_chat_data): + from app.agent.factory.task_summary import summary_subtasks_result + + options = Chat(**sample_chat_data) + + parent = Task(content="Build a web app", id="parent") + sub1 = Task(content="Create frontend", id="sub1") + sub1.result = "Frontend done" + sub2 = Task(content="Create backend", id="sub2") + sub2.result = "Backend done" + parent.add_subtask(sub1) + parent.add_subtask(sub2) + + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [ + MagicMock(content="Both frontend and backend completed.") + ] + mock_agent.step.return_value = mock_resp + + with patch(f"{_mod}._create_summary_agent", return_value=mock_agent): + result = await summary_subtasks_result(parent, options) + + assert result == "Both frontend and backend completed." + call_args = mock_agent.step.call_args[0][0] + assert "Create frontend" in call_args + assert "Create backend" in call_args + assert "Frontend done" in call_args + assert "Backend done" in call_args + + +@pytest.mark.asyncio +async def test_get_task_result_with_optional_summary_multiple_subtasks( + sample_chat_data, +): + from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + ) + + options = Chat(**sample_chat_data) + + parent = Task(content="Multi-step task", id="parent") + parent.result = "Raw aggregated result" + sub1 = Task(content="Step 1", id="s1") + sub1.result = "Step 1 done" + sub2 = Task(content="Step 2", id="s2") + sub2.result = "Step 2 done" + parent.add_subtask(sub1) + parent.add_subtask(sub2) + + mock_agent = MagicMock() + mock_resp = MagicMock() + mock_resp.msgs = [MagicMock(content="Summarized multi-step result")] + mock_agent.step.return_value = mock_resp + + with patch(f"{_mod}._create_summary_agent", return_value=mock_agent): + result = await get_task_result_with_optional_summary(parent, options) + + assert result == "Summarized multi-step result" + + +@pytest.mark.asyncio +async def test_get_task_result_with_optional_summary_single_subtask( + sample_chat_data, +): + from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + ) + + options = Chat(**sample_chat_data) + + parent = Task(content="Single step task", id="parent") + parent.result = "--- Subtask 1 Result ---\nActual result content" + sub1 = Task(content="Step 1", id="s1") + sub1.result = "Step 1 done" + parent.add_subtask(sub1) + + with patch(f"{_mod}._create_summary_agent") as mock_create: + result = await get_task_result_with_optional_summary(parent, options) + + mock_create.assert_not_called() + assert result == "Actual result content" + + +@pytest.mark.asyncio +async def test_get_task_result_with_optional_summary_no_subtasks( + sample_chat_data, +): + from app.agent.factory.task_summary import ( + get_task_result_with_optional_summary, + ) + + options = Chat(**sample_chat_data) + + task = Task(content="Simple task", id="simple") + task.result = "Direct result" + + with patch(f"{_mod}._create_summary_agent") as mock_create: + result = await get_task_result_with_optional_summary(task, options) + + mock_create.assert_not_called() + assert result == "Direct result" diff --git a/backend/tests/app/agent/factory/test_workforce_agents.py b/backend/tests/app/agent/factory/test_workforce_agents.py new file mode 100644 index 000000000..48c0dbec7 --- /dev/null +++ b/backend/tests/app/agent/factory/test_workforce_agents.py @@ -0,0 +1,83 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from unittest.mock import MagicMock, patch + +import pytest + +from app.agent.factory.workforce_agents import ( + _env_prompt, + create_coordinator_and_task_agents, + create_new_worker_agent, +) +from app.model.chat import Chat +from app.service.task import Agents + +pytestmark = pytest.mark.unit + +_mod = "app.agent.factory.workforce_agents" + + +def test_env_prompt_contains_platform_info(): + result = _env_prompt("/tmp/workdir") + assert "working directory" in result.lower() or "/tmp/workdir" in result + assert "/tmp/workdir" in result + + +def test_env_prompt_contains_current_date(): + import datetime + + result = _env_prompt("/project") + today = str(datetime.date.today()) + assert today in result + + +def test_create_coordinator_and_task_agents_returns_two(sample_chat_data): + options = Chat(**sample_chat_data) + + mock_agent = MagicMock() + + with ( + patch(f"{_mod}.agent_model", return_value=mock_agent) as mock_am, + patch(f"{_mod}.HumanToolkit"), + patch(f"{_mod}.NoteTakingToolkit"), + patch(f"{_mod}.SkillToolkit"), + patch(f"{_mod}.ToolkitMessageIntegration"), + ): + agents = create_coordinator_and_task_agents(options, "/tmp/workdir") + + assert len(agents) == 2 + assert mock_am.call_count == 2 + call_keys = [call.args[0] for call in mock_am.call_args_list] + assert Agents.coordinator_agent in call_keys + assert Agents.task_agent in call_keys + + +def test_create_new_worker_agent_returns_agent(sample_chat_data): + options = Chat(**sample_chat_data) + + mock_agent = MagicMock() + + with ( + patch(f"{_mod}.agent_model", return_value=mock_agent) as mock_am, + patch(f"{_mod}.HumanToolkit"), + patch(f"{_mod}.NoteTakingToolkit"), + patch(f"{_mod}.SkillToolkit"), + patch(f"{_mod}.ToolkitMessageIntegration"), + ): + result = create_new_worker_agent(options, "/tmp/workdir") + + assert result is mock_agent + mock_am.assert_called_once() + assert mock_am.call_args.args[0] == Agents.new_worker_agent diff --git a/backend/tests/app/service/chat_service/__init__.py b/backend/tests/app/service/chat_service/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/backend/tests/app/service/chat_service/__init__.py @@ -0,0 +1,13 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= diff --git a/backend/tests/app/service/chat_service/test_lifecycle.py b/backend/tests/app/service/chat_service/test_lifecycle.py new file mode 100644 index 000000000..03d00da26 --- /dev/null +++ b/backend/tests/app/service/chat_service/test_lifecycle.py @@ -0,0 +1,394 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from unittest.mock import MagicMock, patch + +import pytest +from camel.tasks import Task +from camel.tasks.task import TaskState + +from app.model.chat import Chat, NewAgent, TaskContent +from app.service.chat_service.lifecycle import ( + add_sub_tasks, + construct_workforce, + format_agent_description, + install_mcp, + new_agent_model, + to_sub_tasks, + tree_sub_tasks, + update_sub_tasks, +) +from app.service.task import ActionInstallMcpData, ActionNewAgent + +pytestmark = pytest.mark.unit + + +# --- tree_sub_tasks --- + + +def test_tree_sub_tasks_simple(): + task1 = Task(content="Task 1", id="task_1") + task1.state = TaskState.OPEN + task2 = Task(content="Task 2", id="task_2") + task2.state = TaskState.RUNNING + + result = tree_sub_tasks([task1, task2]) + + assert len(result) == 2 + assert result[0]["id"] == "task_1" + assert result[0]["content"] == "Task 1" + assert result[0]["state"] == TaskState.OPEN + assert result[1]["id"] == "task_2" + assert result[1]["content"] == "Task 2" + assert result[1]["state"] == TaskState.RUNNING + + +def test_tree_sub_tasks_with_nested_subtasks(): + parent_task = Task(content="Parent Task", id="parent") + parent_task.state = TaskState.RUNNING + child_task = Task(content="Child Task", id="child") + child_task.state = TaskState.OPEN + parent_task.add_subtask(child_task) + + result = tree_sub_tasks([parent_task]) + + assert len(result) == 1 + assert result[0]["id"] == "parent" + assert result[0]["content"] == "Parent Task" + assert len(result[0]["subtasks"]) == 1 + assert result[0]["subtasks"][0]["id"] == "child" + assert result[0]["subtasks"][0]["content"] == "Child Task" + + +def test_tree_sub_tasks_filters_empty_content(): + task1 = Task(content="Valid Task", id="task_1") + task1.state = TaskState.OPEN + task2 = Task(content="", id="task_2") + task2.state = TaskState.OPEN + + result = tree_sub_tasks([task1, task2]) + + assert len(result) == 1 + assert result[0]["id"] == "task_1" + + +def test_tree_sub_tasks_depth_limit(): + current_task = Task(content="Root", id="root") + for i in range(10): + child_task = Task(content=f"Level {i + 1}", id=f"level_{i + 1}") + current_task.add_subtask(child_task) + current_task = child_task + + result = tree_sub_tasks([Task(content="Root", id="root")]) + assert isinstance(result, list) + + +def test_tree_sub_tasks_with_none_content(): + task1 = Task(content="Valid Task", id="task_1") + task1.state = TaskState.OPEN + task2 = Task(content="", id="task_2") + task2.state = TaskState.OPEN + + result = tree_sub_tasks([task1, task2]) + assert len(result) <= 1 + + +# --- update_sub_tasks --- + + +def test_update_sub_tasks_success(): + task1 = Task(content="Original Content 1", id="task_1") + task2 = Task(content="Original Content 2", id="task_2") + task3 = Task(content="Original Content 3", id="task_3") + + update_tasks = { + "task_2": TaskContent(id="task_2", content="Updated Content 2"), + "task_3": TaskContent(id="task_3", content="Updated Content 3"), + } + + result = update_sub_tasks([task1, task2, task3], update_tasks) + + assert len(result) == 2 + assert result[0].content == "Updated Content 2" + assert result[1].content == "Updated Content 3" + + +def test_update_sub_tasks_with_nested_tasks(): + parent_task = Task(content="Parent", id="parent") + child_task = Task(content="Original Child", id="child") + parent_task.add_subtask(child_task) + + update_tasks = { + "parent": TaskContent(id="parent", content="Parent"), + "child": TaskContent(id="child", content="Updated Child"), + } + + result = update_sub_tasks([parent_task], update_tasks, depth=0) + assert len(result) == 1 + + +# --- add_sub_tasks --- + + +def test_add_sub_tasks_to_camel_task(): + camel_task = Task(content="Main Task", id="main") + new_tasks = [ + TaskContent(id="", content="New Task 1"), + TaskContent(id="", content="New Task 2"), + ] + + initial_subtask_count = len(camel_task.subtasks) + add_sub_tasks(camel_task, new_tasks) + + assert len(camel_task.subtasks) == initial_subtask_count + 2 + new_subtasks = camel_task.subtasks[-2:] + assert new_subtasks[0].content == "New Task 1" + assert new_subtasks[1].content == "New Task 2" + assert new_subtasks[0].id.startswith("main.") + assert new_subtasks[1].id.startswith("main.") + + +# --- to_sub_tasks --- + + +def test_to_sub_tasks_creates_proper_response(): + task = Task(content="Main Task", id="main") + subtask = Task(content="Sub Task", id="sub") + subtask.state = TaskState.OPEN + task.add_subtask(subtask) + + result = to_sub_tasks(task, "Task Summary") + + assert "to_sub_tasks" in result + assert "summary_task" in result + assert "sub_tasks" in result + + +# --- format_agent_description --- + + +def test_format_agent_description_basic(): + agent_data = NewAgent( + name="TestAgent", + description="A test agent for testing", + tools=["search", "code"], + mcp_tools=None, + env_path=".env", + ) + result = format_agent_description(agent_data) + assert "TestAgent:" in result + assert "A test agent for testing" in result + assert "Search" in result + assert "Code" in result + + +def test_format_agent_description_with_mcp_tools(): + agent_data = NewAgent( + name="MCPAgent", + description="An agent with MCP tools", + tools=["search"], + mcp_tools={"mcpServers": {"notion": {}, "slack": {}}}, + env_path=".env", + ) + result = format_agent_description(agent_data) + assert "MCPAgent:" in result + assert "An agent with MCP tools" in result + assert "Notion" in result + assert "Slack" in result + + +def test_format_agent_description_no_description(): + agent_data = NewAgent( + name="SimpleAgent", + description="", + tools=["search"], + mcp_tools=None, + env_path=".env", + ) + result = format_agent_description(agent_data) + assert "SimpleAgent:" in result + assert "A specialized agent" in result + + +def test_format_agent_description_with_none_values(): + agent_data = ActionNewAgent( + name="TestAgent", + description="", + tools=[], + mcp_tools=None, + ) + result = format_agent_description(agent_data) + assert "TestAgent:" in result + assert "A specialized agent" in result + + +# --- new_agent_model --- + + +@pytest.mark.asyncio +async def test_new_agent_model_creation(sample_chat_data): + options = Chat(**sample_chat_data) + agent_data = NewAgent( + name="TestAgent", + description="A test agent", + tools=["search", "code"], + mcp_tools=None, + env_path=".env", + ) + mock_agent = MagicMock() + + with ( + patch( + "app.service.chat_service.lifecycle.get_toolkits", + return_value=[], + ), + patch( + "app.service.chat_service.lifecycle.get_mcp_tools", + return_value=[], + ), + patch( + "app.service.chat_service.lifecycle.agent_model", + return_value=mock_agent, + ), + ): + result = await new_agent_model(agent_data, options) + assert result is mock_agent + + +@pytest.mark.asyncio +async def test_new_agent_model_with_invalid_tools(sample_chat_data): + options = Chat(**sample_chat_data) + agent_data = NewAgent( + name="InvalidAgent", + description="Agent with invalid tools", + tools=["nonexistent_tool"], + mcp_tools=None, + env_path=".env", + ) + + with patch( + "app.service.chat_service.lifecycle.get_toolkits", + side_effect=Exception("Invalid tool"), + ): + with pytest.raises(Exception, match="Invalid tool"): + await new_agent_model(agent_data, options) + + +# --- construct_workforce --- + + +@pytest.mark.asyncio +async def test_construct_workforce(sample_chat_data, mock_task_lock): + options = Chat(**sample_chat_data) + mock_workforce = MagicMock() + mock_mcp_agent = MagicMock() + + with ( + patch( + "app.service.chat_service.lifecycle.agent_model" + ) as mock_agent_model, + patch( + "app.agent.factory.workforce_agents.agent_model" + ) as mock_wf_agent_model, + patch( + "app.service.chat_service.lifecycle.get_working_directory", + return_value="/tmp/test_workdir", + ), + patch( + "app.service.chat_service.lifecycle.Workforce", + return_value=mock_workforce, + ), + patch("app.service.chat_service.lifecycle.browser_agent"), + patch("app.service.chat_service.lifecycle.developer_agent"), + patch("app.service.chat_service.lifecycle.document_agent"), + patch("app.service.chat_service.lifecycle.multi_modal_agent"), + patch( + "app.service.chat_service.lifecycle.mcp_agent", + return_value=mock_mcp_agent, + ), + patch( + "app.agent.toolkit.human_toolkit.get_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.service.chat_service.lifecycle.WorkforceMetricsCallback", + return_value=MagicMock(), + ), + ): + mock_agent_model.return_value = MagicMock() + mock_wf_agent_model.return_value = MagicMock() + + workforce, mcp = await construct_workforce(options) + + assert workforce is mock_workforce + assert mcp is mock_mcp_agent + assert mock_workforce.add_single_agent_worker.call_count >= 4 + + +@pytest.mark.asyncio +async def test_construct_workforce_agent_creation_error( + sample_chat_data, mock_task_lock +): + options = Chat(**sample_chat_data) + + with ( + patch( + "app.agent.toolkit.human_toolkit.get_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.service.chat_service.lifecycle.agent_model", + side_effect=Exception("Agent creation failed"), + ), + patch( + "app.agent.factory.workforce_agents.agent_model", + side_effect=Exception("Agent creation failed"), + ), + patch( + "app.agent.factory.developer.agent_model", + side_effect=Exception("Agent creation failed"), + ), + patch( + "app.agent.factory.browser.agent_model", + side_effect=Exception("Agent creation failed"), + ), + patch( + "app.agent.factory.document.agent_model", + side_effect=Exception("Agent creation failed"), + ), + patch( + "app.agent.factory.multi_modal.agent_model", + side_effect=Exception("Agent creation failed"), + ), + ): + with pytest.raises(Exception, match="Agent creation failed"): + await construct_workforce(options) + + +# --- install_mcp --- + + +@pytest.mark.asyncio +async def test_install_mcp_success(mock_camel_agent): + mock_tools = [MagicMock(), MagicMock()] + install_data = ActionInstallMcpData( + data={"mcpServers": {"notion": {"config": "test"}}} + ) + + with patch( + "app.service.chat_service.lifecycle.get_mcp_tools", + return_value=mock_tools, + ): + await install_mcp(mock_camel_agent, install_data) + mock_camel_agent.add_tools.assert_called_once_with(mock_tools) diff --git a/backend/tests/app/service/chat_service/test_step_solve.py b/backend/tests/app/service/chat_service/test_step_solve.py new file mode 100644 index 000000000..343e0cd49 --- /dev/null +++ b/backend/tests/app/service/chat_service/test_step_solve.py @@ -0,0 +1,209 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.model.chat import Chat +from app.service.chat_service.step_solve import step_solve +from app.service.task import ( + Action, + ActionEndData, + ActionImproveData, + ImprovePayload, + TaskLock, +) +from app.utils.context import ( + build_conversation_context, + collect_previous_task_context, +) + +# --- integration tests --- + + +@pytest.mark.asyncio +async def test_step_solve_context_building_workflow( + sample_chat_data, mock_request, temp_dir +): + options = Chat(**sample_chat_data) + task_lock = TaskLock(id="test_task_123", queue=AsyncMock(), human_input={}) + task_lock.conversation_history = [ + {"role": "user", "content": "Create a Python script"}, + {"role": "assistant", "content": "Script created successfully"}, + ] + task_lock.last_task_result = "def hello(): print('Hello World')" + task_lock.last_task_summary = "Python Hello World Script" + + working_dir = temp_dir / "test_project" + working_dir.mkdir() + (working_dir / "script.py").write_text("def hello(): print('Hello World')") + + context = build_conversation_context(task_lock) + assert "=== CONVERSATION HISTORY ===" in context + assert "Script created successfully" in context + + +@pytest.mark.asyncio +async def test_step_solve_new_task_state_context_collection( + sample_chat_data, mock_request, temp_dir +): + Chat(**sample_chat_data) + working_dir = temp_dir / "project" + working_dir.mkdir() + (working_dir / "main.py").write_text("print('main')") + (working_dir / "config.json").write_text('{"version": "1.0"}') + + with patch.object(Chat, "file_save_path", return_value=str(working_dir)): + result = collect_previous_task_context( + working_directory=str(working_dir), + previous_task_content="Create project structure", + previous_task_result="Project files created successfully", + previous_summary="Project Setup Task", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "Create project structure" in result + assert "Project Setup Task" in result + assert "Project files created successfully" in result + assert "main.py" in result + assert "config.json" in result + assert "=== END OF PREVIOUS TASK CONTEXT ===" in result + + +@pytest.mark.asyncio +async def test_step_solve_end_action_context_collection( + sample_chat_data, mock_request, temp_dir +): + Chat(**sample_chat_data) + working_dir = temp_dir / "finished_project" + working_dir.mkdir() + (working_dir / "output.txt").write_text("Final output") + (working_dir / "report.md").write_text("# Task Report") + + task_lock = TaskLock(id="test_end_task", queue=AsyncMock(), human_input={}) + task_lock.last_task_summary = "Final Task Summary" + + with patch.object(Chat, "file_save_path", return_value=str(working_dir)): + context = collect_previous_task_context( + working_directory=str(working_dir), + previous_task_content="Generate final report", + previous_task_result="Report generated successfully with output files", + previous_summary=task_lock.last_task_summary, + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in context + assert "Generate final report" in context + assert "Report generated successfully with output files" in context + assert "Final Task Summary" in context + assert "output.txt" in context + assert "report.md" in context + + task_lock.add_conversation("task_result", context) + assert len(task_lock.conversation_history) == 1 + assert task_lock.conversation_history[0]["role"] == "task_result" + assert task_lock.conversation_history[0]["content"] == context + + +@pytest.mark.asyncio +@pytest.mark.skip(reason="Gets Stuck for some reason.") +async def test_step_solve_basic_workflow( + sample_chat_data, mock_request, mock_task_lock +): + options = Chat(**sample_chat_data) + mock_task_lock.get_queue = AsyncMock( + side_effect=[ + ActionImproveData( + action=Action.improve, + data=ImprovePayload(question="Test question"), + ), + ActionEndData(action=Action.end), + ] + ) + + mock_workforce = MagicMock() + mock_mcp = MagicMock() + + with ( + patch( + "app.service.chat_service.decomposition.construct_workforce", + return_value=(mock_workforce, mock_mcp), + ), + patch( + "app.service.chat_service.question_router.question_confirm", + return_value=True, + ), + patch( + "app.service.chat_service.question_router.summary_task", + return_value="Test Summary", + ), + ): + mock_workforce.eigent_make_sub_tasks.return_value = [] + responses = [] + async for response in step_solve( + options, mock_request, mock_task_lock + ): + responses.append(response) + if len(responses) > 10: + break + assert len(responses) > 0 + + +@pytest.mark.asyncio +async def test_step_solve_with_disconnected_request( + sample_chat_data, mock_request, mock_task_lock +): + options = Chat(**sample_chat_data) + mock_request.is_disconnected = AsyncMock(return_value=True) + mock_workforce = MagicMock() + + with patch( + "app.service.chat_service.decomposition.construct_workforce", + return_value=(mock_workforce, MagicMock()), + ): + responses = [] + async for response in step_solve( + options, mock_request, mock_task_lock + ): + responses.append(response) + assert len(responses) == 0 + + +@pytest.mark.asyncio +@pytest.mark.skip(reason="Gets Stuck for some reason.") +async def test_step_solve_error_handling( + sample_chat_data, mock_request, mock_task_lock +): + options = Chat(**sample_chat_data) + mock_task_lock.get_queue = AsyncMock(side_effect=Exception("Queue error")) + + responses = [] + async for response in step_solve(options, mock_request, mock_task_lock): + responses.append(response) + break + assert len(responses) == 0 + + +# --- LLM tests --- + + +@pytest.mark.model_backend +@pytest.mark.asyncio +async def test_construct_workforce_with_real_agents(sample_chat_data): + Chat(**sample_chat_data) + assert True # Placeholder + + +@pytest.mark.very_slow +async def test_full_chat_workflow_integration(sample_chat_data, mock_request): + Chat(**sample_chat_data) + assert True # Placeholder diff --git a/backend/tests/app/service/test_chat_service.py b/backend/tests/app/service/test_chat_service.py deleted file mode 100644 index e9b61e044..000000000 --- a/backend/tests/app/service/test_chat_service.py +++ /dev/null @@ -1,1301 +0,0 @@ -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from camel.tasks import Task -from camel.tasks.task import TaskState - -from app.model.chat import Chat, NewAgent -from app.service.chat_service import ( - add_sub_tasks, - build_conversation_context, - collect_previous_task_context, - construct_workforce, - format_agent_description, - format_task_context, - install_mcp, - new_agent_model, - question_confirm, - step_solve, - summary_task, - to_sub_tasks, - tree_sub_tasks, - update_sub_tasks, -) -from app.service.task import ( - Action, - ActionEndData, - ActionImproveData, - ActionInstallMcpData, - ImprovePayload, - TaskLock, -) - - -@pytest.mark.unit -class TestFormatTaskContext: - """Test cases for format_task_context function.""" - - def test_format_task_context_with_working_directory_and_files( - self, temp_dir - ): - """Test format_task_context lists generated files via list_files.""" - (temp_dir / "output.txt").write_text("content") - task_data = { - "task_content": "Create file", - "task_result": "Done", - "working_directory": str(temp_dir), - } - result = format_task_context(task_data, skip_files=False) - assert "Previous Task: Create file" in result - assert "output.txt" in result - assert "Generated Files from Previous Task:" in result - - def test_format_task_context_skip_files(self, temp_dir): - """Test format_task_context with skip_files=True omits file listing.""" - task_data = { - "task_content": "Task", - "task_result": "Result", - "working_directory": str(temp_dir), - } - result = format_task_context(task_data, skip_files=True) - assert "Generated Files from Previous Task:" not in result - - -@pytest.mark.unit -class TestCollectPreviousTaskContext: - """Test cases for collect_previous_task_context function.""" - - def test_collect_previous_task_context_basic(self, temp_dir): - """Test collect_previous_task_context with basic inputs.""" - working_directory = str(temp_dir) - previous_task_content = "Create a Python script" - previous_task_result = "Successfully created script.py" - previous_summary = "Python Script Creation Task" - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content=previous_task_content, - previous_task_result=previous_task_result, - previous_summary=previous_summary, - ) - - # Check that all sections are included - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "Previous Task:" in result - assert "Create a Python script" in result - assert "Previous Task Summary:" in result - assert "Python Script Creation Task" in result - assert "Previous Task Result:" in result - assert "Successfully created script.py" in result - assert "=== END OF PREVIOUS TASK CONTEXT ===" in result - - def test_collect_previous_task_context_with_generated_files( - self, temp_dir - ): - """Test collect_previous_task_context with generated files in working directory.""" - working_directory = str(temp_dir) - - # Create some test files - (temp_dir / "script.py").write_text("print('Hello World')") - (temp_dir / "config.json").write_text('{"test": true}') - (temp_dir / "README.md").write_text("# Test Project") - - # Create a subdirectory with files - sub_dir = temp_dir / "utils" - sub_dir.mkdir() - (sub_dir / "helper.py").write_text("def helper(): pass") - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Create project files", - previous_task_result="Files created successfully", - previous_summary="", - ) - - # Check that generated files are listed - assert "Generated Files from Previous Task:" in result - assert "script.py" in result - assert "config.json" in result - assert "README.md" in result - assert ( - "utils/helper.py" in result or "utils\\helper.py" in result - ) # Handle Windows paths - - # Files should be sorted - lines = result.split("\n") - file_lines = [ - line.strip() for line in lines if line.strip().startswith("- ") - ] - assert len(file_lines) == 4 - - def test_collect_previous_task_context_filters_hidden_files( - self, temp_dir - ): - """Test that hidden files and directories are filtered out.""" - working_directory = str(temp_dir) - - # Create regular files - (temp_dir / "visible.py").write_text("# Visible file") - - # Create hidden files and directories - (temp_dir / ".hidden_file").write_text("hidden content") - (temp_dir / ".env").write_text("SECRET=hidden") - - hidden_dir = temp_dir / ".hidden_dir" - hidden_dir.mkdir() - (hidden_dir / "file.txt").write_text("in hidden dir") - - # Create cache directories - cache_dir = temp_dir / "__pycache__" - cache_dir.mkdir() - (cache_dir / "module.pyc").write_text("compiled") - - node_modules = temp_dir / "node_modules" - node_modules.mkdir() - (node_modules / "package").mkdir() - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test filtering", - previous_task_result="Files filtered", - previous_summary="", - ) - - # Should only include visible files - assert "visible.py" in result - assert ".hidden_file" not in result - assert ".env" not in result - assert "__pycache__" not in result - assert "node_modules" not in result - assert ".hidden_dir" not in result - - def test_collect_previous_task_context_filters_temp_files(self, temp_dir): - """Test that temporary files are filtered out.""" - working_directory = str(temp_dir) - - # Create regular files - (temp_dir / "main.py").write_text("# Main file") - - # Create temporary files - (temp_dir / "temp.tmp").write_text("temporary") - (temp_dir / "compiled.pyc").write_text("compiled python") - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test temp filtering", - previous_task_result="Temp files filtered", - previous_summary="", - ) - - # Should only include regular files - assert "main.py" in result - assert "temp.tmp" not in result - assert "compiled.pyc" not in result - - def test_collect_previous_task_context_nonexistent_directory(self): - """Test collect_previous_task_context with non-existent working directory.""" - working_directory = "/nonexistent/directory" - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test task", - previous_task_result="Test result", - previous_summary="Test summary", - ) - - # Should not crash and should not include file listing - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "Test task" in result - assert "Test result" in result - assert "Test summary" in result - assert "Generated Files from Previous Task:" not in result - - def test_collect_previous_task_context_empty_inputs(self, temp_dir): - """Test collect_previous_task_context with empty string inputs.""" - working_directory = str(temp_dir) - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="", - previous_task_result="", - previous_summary="", - ) - - # Should still have the structural elements - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "=== END OF PREVIOUS TASK CONTEXT ===" in result - - # Should not have content sections for empty inputs - assert "Previous Task:" not in result - assert "Previous Task Summary:" not in result - assert "Previous Task Result:" not in result - - def test_collect_previous_task_context_only_summary(self, temp_dir): - """Test collect_previous_task_context with only summary provided.""" - working_directory = str(temp_dir) - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="", - previous_task_result="", - previous_summary="Only summary provided", - ) - - # Should include summary section only - assert "Previous Task Summary:" in result - assert "Only summary provided" in result - assert "Previous Task:" not in result - assert "Previous Task Result:" not in result - - @patch("app.utils.file_utils.logger") - def test_collect_previous_task_context_file_system_error( - self, mock_logger, temp_dir - ): - """Test collect_previous_task_context handles file system errors gracefully.""" - working_directory = str(temp_dir) - - # Mock os.walk to raise an exception (used inside list_files) - with patch("os.walk", side_effect=PermissionError("Access denied")): - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test task", - previous_task_result="Test result", - previous_summary="Test summary", - ) - - # Should still return result without files - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "Test task" in result - assert "Generated Files from Previous Task:" not in result - - # Warning is logged by file_utils.list_files - mock_logger.warning.assert_called_once() - - def test_collect_previous_task_context_relative_paths(self, temp_dir): - """Test that file paths are correctly converted to relative paths.""" - working_directory = str(temp_dir) - - # Create nested directory structure - deep_dir = temp_dir / "level1" / "level2" / "level3" - deep_dir.mkdir(parents=True) - (deep_dir / "deep_file.txt").write_text("deep content") - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test relative paths", - previous_task_result="Paths converted", - previous_summary="", - ) - - # Check that the path is relative to working directory - expected_path = "level1/level2/level3/deep_file.txt" - windows_path = "level1\\level2\\level3\\deep_file.txt" - - # Should contain relative path (handle both Unix and Windows separators) - assert expected_path in result or windows_path in result - - -@pytest.mark.unit -class TestBuildConversationContext: - """Test cases for build_conversation_context function.""" - - def test_build_conversation_context_basic(self, temp_dir): - """Test build_conversation_context with basic task lock.""" - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [ - { - "role": "assistant", - "content": "I will create a Python script for you", - }, - ] - - result = build_conversation_context(task_lock) - - # Should include conversation history header - assert "=== CONVERSATION HISTORY ===" in result - assert "I will create a Python script for you" in result - - def test_build_conversation_context_empty_history(self, temp_dir): - """Test build_conversation_context with empty conversation history.""" - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [] - - result = build_conversation_context(task_lock) - - # Should return empty string for no context - assert result == "" - - def test_build_conversation_context_task_result_role(self, temp_dir): - """Test build_conversation_context handles 'task_result' role.""" - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [ - { - "role": "task_result", - "content": "Full task context from previous task", - }, - { - "role": "assistant", - "content": "Task completed successfully", - }, - ] - - result = build_conversation_context(task_lock) - - # build_conversation_context appends string task_result content directly - assert "Full task context from previous task" in result - assert "Task completed successfully" in result - - def test_build_conversation_context_with_assistant_entries(self, temp_dir): - """Test build_conversation_context with assistant entries.""" - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [ - { - "role": "assistant", - "content": "Task completed with output.txt", - }, - ] - - result = build_conversation_context(task_lock) - - # Should include conversation history - assert "=== CONVERSATION HISTORY ===" in result - assert "Task completed with output.txt" in result - - -@pytest.mark.unit -class TestChatServiceUtilities: - """Test cases for chat service utility functions.""" - - def test_tree_sub_tasks_simple(self): - """Test tree_sub_tasks with simple task structure.""" - task1 = Task(content="Task 1", id="task_1") - task1.state = TaskState.OPEN - task2 = Task(content="Task 2", id="task_2") - task2.state = TaskState.RUNNING - - sub_tasks = [task1, task2] - result = tree_sub_tasks(sub_tasks) - - assert len(result) == 2 - assert result[0]["id"] == "task_1" - assert result[0]["content"] == "Task 1" - assert result[0]["state"] == TaskState.OPEN - assert result[1]["id"] == "task_2" - assert result[1]["content"] == "Task 2" - assert result[1]["state"] == TaskState.RUNNING - - def test_tree_sub_tasks_with_nested_subtasks(self): - """Test tree_sub_tasks with nested subtask structure.""" - parent_task = Task(content="Parent Task", id="parent") - parent_task.state = TaskState.RUNNING - - child_task = Task(content="Child Task", id="child") - child_task.state = TaskState.OPEN - parent_task.add_subtask(child_task) - - result = tree_sub_tasks([parent_task]) - - assert len(result) == 1 - assert result[0]["id"] == "parent" - assert result[0]["content"] == "Parent Task" - assert len(result[0]["subtasks"]) == 1 - assert result[0]["subtasks"][0]["id"] == "child" - assert result[0]["subtasks"][0]["content"] == "Child Task" - - def test_tree_sub_tasks_filters_empty_content(self): - """Test tree_sub_tasks filters out tasks with empty content.""" - task1 = Task(content="Valid Task", id="task_1") - task1.state = TaskState.OPEN - task2 = Task(content="", id="task_2") # Empty content - task2.state = TaskState.OPEN - - result = tree_sub_tasks([task1, task2]) - - assert len(result) == 1 - assert result[0]["id"] == "task_1" - - def test_tree_sub_tasks_depth_limit(self): - """Test tree_sub_tasks respects depth limit.""" - # Create deeply nested structure - current_task = Task(content="Root", id="root") - - for i in range(10): - child_task = Task(content=f"Level {i + 1}", id=f"level_{i + 1}") - current_task.add_subtask(child_task) - current_task = child_task - - result = tree_sub_tasks([Task(content="Root", id="root")]) - - # Should not exceed depth limit (function should handle deep nesting gracefully) - assert isinstance(result, list) - - def test_update_sub_tasks_success(self): - """Test update_sub_tasks updates existing tasks correctly.""" - from app.model.chat import TaskContent - - task1 = Task(content="Original Content 1", id="task_1") - task2 = Task(content="Original Content 2", id="task_2") - task3 = Task(content="Original Content 3", id="task_3") - - sub_tasks = [task1, task2, task3] - - update_tasks = { - "task_2": TaskContent(id="task_2", content="Updated Content 2"), - "task_3": TaskContent(id="task_3", content="Updated Content 3"), - } - - result = update_sub_tasks(sub_tasks, update_tasks) - - assert len(result) == 2 # Only updated tasks remain - assert result[0].content == "Updated Content 2" - assert result[1].content == "Updated Content 3" - - def test_update_sub_tasks_with_nested_tasks(self): - """Test update_sub_tasks handles nested task updates.""" - from app.model.chat import TaskContent - - parent_task = Task(content="Parent", id="parent") - child_task = Task(content="Original Child", id="child") - parent_task.add_subtask(child_task) - - sub_tasks = [parent_task] - update_tasks = { - "parent": TaskContent( - id="parent", content="Parent" - ), # Include parent to keep it - "child": TaskContent(id="child", content="Updated Child"), - } - - result = update_sub_tasks(sub_tasks, update_tasks, depth=0) - - # Parent task should remain with updated child - assert len(result) == 1 - # Note: The actual behavior depends on the implementation details - - def test_add_sub_tasks_to_camel_task(self): - """Test add_sub_tasks adds new tasks to CAMEL task.""" - from app.model.chat import TaskContent - - camel_task = Task(content="Main Task", id="main") - - new_tasks = [ - TaskContent(id="", content="New Task 1"), - TaskContent(id="", content="New Task 2"), - ] - - initial_subtask_count = len(camel_task.subtasks) - add_sub_tasks(camel_task, new_tasks) - - assert len(camel_task.subtasks) == initial_subtask_count + 2 - - # Check that new subtasks were added with proper IDs - new_subtasks = camel_task.subtasks[-2:] - assert new_subtasks[0].content == "New Task 1" - assert new_subtasks[1].content == "New Task 2" - assert new_subtasks[0].id.startswith("main.") - assert new_subtasks[1].id.startswith("main.") - - def test_to_sub_tasks_creates_proper_response(self): - """Test to_sub_tasks creates properly formatted SSE response.""" - task = Task(content="Main Task", id="main") - subtask = Task(content="Sub Task", id="sub") - subtask.state = TaskState.OPEN - task.add_subtask(subtask) - - summary_content = "Task Summary" - - result = to_sub_tasks(task, summary_content) - - # Should be a JSON string formatted for SSE - assert "to_sub_tasks" in result - assert "summary_task" in result - assert "sub_tasks" in result - - def test_format_agent_description_basic(self): - """Test format_agent_description with basic agent data.""" - agent_data = NewAgent( - name="TestAgent", - description="A test agent for testing", - tools=["search", "code"], - mcp_tools=None, - env_path=".env", - ) - - result = format_agent_description(agent_data) - - assert "TestAgent:" in result - assert "A test agent for testing" in result - assert "Search" in result # Should titleize tool names - assert "Code" in result - - def test_format_agent_description_with_mcp_tools(self): - """Test format_agent_description with MCP tools.""" - agent_data = NewAgent( - name="MCPAgent", - description="An agent with MCP tools", - tools=["search"], - mcp_tools={"mcpServers": {"notion": {}, "slack": {}}}, - env_path=".env", - ) - - result = format_agent_description(agent_data) - - assert "MCPAgent:" in result - assert "An agent with MCP tools" in result - assert "Notion" in result - assert "Slack" in result - - def test_format_agent_description_no_description(self): - """Test format_agent_description without description.""" - agent_data = NewAgent( - name="SimpleAgent", - description="", - tools=["search"], - mcp_tools=None, - env_path=".env", - ) - - result = format_agent_description(agent_data) - - assert "SimpleAgent:" in result - assert "A specialized agent" in result # Default description - - -@pytest.mark.unit -class TestChatServiceAgentOperations: - """Test cases for agent-related chat service operations.""" - - @pytest.mark.asyncio - async def test_question_confirm_simple_query(self, mock_camel_agent): - """Test question_confirm with simple query returns False.""" - mock_camel_agent.step.return_value.msgs[0].content = "no" - - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [] - task_lock.question_agent = mock_camel_agent - - options = MagicMock() - result = await question_confirm("hello", options, task_lock) - - # Should return False for simple queries (no "yes" in response) - assert result is False - - @pytest.mark.asyncio - async def test_question_confirm_complex_task(self, mock_camel_agent): - """Test question_confirm with complex task that should proceed.""" - mock_camel_agent.step.return_value.msgs[0].content = "yes" - - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [] - task_lock.question_agent = mock_camel_agent - - options = MagicMock() - result = await question_confirm( - "Create a web application with authentication", - options, - task_lock, - ) - - # Should return True for complex tasks - assert result is True - - @pytest.mark.asyncio - async def test_summary_task(self, mock_camel_agent): - """Test summary_task creates proper task summary.""" - mock_camel_agent.step.return_value.msgs[ - 0 - ].content = "Web App Creation|Create a modern web application with user authentication and dashboard" - - task = Task( - content="Create a web application with user authentication", - id="web_app_task", - ) - - options = MagicMock() - with patch( - "app.agent.factory.task_summary._create_summary_agent", - return_value=mock_camel_agent, - ): - result = await summary_task(task, options) - - assert ( - result - == "Web App Creation|Create a modern web application with user authentication and dashboard" - ) - mock_camel_agent.step.assert_called_once() - - @pytest.mark.asyncio - async def test_new_agent_model_creation(self, sample_chat_data): - """Test new_agent_model creates agent with proper configuration.""" - options = Chat(**sample_chat_data) - agent_data = NewAgent( - name="TestAgent", - description="A test agent", - tools=["search", "code"], - mcp_tools=None, - env_path=".env", - ) - - mock_agent = MagicMock() - - with ( - patch( - "app.service.chat_service.lifecycle.get_toolkits", - return_value=[], - ), - patch( - "app.service.chat_service.lifecycle.get_mcp_tools", - return_value=[], - ), - patch( - "app.service.chat_service.lifecycle.agent_model", - return_value=mock_agent, - ), - ): - result = await new_agent_model(agent_data, options) - - assert result is mock_agent - - @pytest.mark.asyncio - async def test_construct_workforce(self, sample_chat_data, mock_task_lock): - """Test construct_workforce creates workforce with proper agents.""" - options = Chat(**sample_chat_data) - - mock_workforce = MagicMock() - mock_mcp_agent = MagicMock() - - with ( - patch( - "app.service.chat_service.lifecycle.agent_model" - ) as mock_agent_model, - patch( - "app.agent.factory.workforce_agents.agent_model" - ) as mock_wf_agent_model, - patch( - "app.service.chat_service.lifecycle.get_working_directory", - return_value="/tmp/test_workdir", - ), - patch( - "app.service.chat_service.lifecycle.Workforce", - return_value=mock_workforce, - ), - patch("app.service.chat_service.lifecycle.browser_agent"), - patch("app.service.chat_service.lifecycle.developer_agent"), - patch("app.service.chat_service.lifecycle.document_agent"), - patch("app.service.chat_service.lifecycle.multi_modal_agent"), - patch( - "app.service.chat_service.lifecycle.mcp_agent", - return_value=mock_mcp_agent, - ), - patch( - "app.agent.toolkit.human_toolkit.get_task_lock", - return_value=mock_task_lock, - ), - patch( - "app.service.chat_service.lifecycle.WorkforceMetricsCallback", - return_value=MagicMock(), - ), - ): - mock_agent_model.return_value = MagicMock() - mock_wf_agent_model.return_value = MagicMock() - - workforce, mcp = await construct_workforce(options) - - assert workforce is mock_workforce - assert mcp is mock_mcp_agent - - # Should add multiple agent workers - assert mock_workforce.add_single_agent_worker.call_count >= 4 - - @pytest.mark.asyncio - async def test_install_mcp_success(self, mock_camel_agent): - """Test install_mcp successfully installs MCP tools.""" - mock_tools = [MagicMock(), MagicMock()] - install_data = ActionInstallMcpData( - data={"mcpServers": {"notion": {"config": "test"}}} - ) - - with patch( - "app.service.chat_service.lifecycle.get_mcp_tools", - return_value=mock_tools, - ): - await install_mcp(mock_camel_agent, install_data) - - mock_camel_agent.add_tools.assert_called_once_with(mock_tools) - - -@pytest.mark.integration -class TestChatServiceIntegration: - """Integration tests for chat service.""" - - @pytest.mark.asyncio - async def test_step_solve_context_building_workflow( - self, sample_chat_data, mock_request, temp_dir - ): - """Test step_solve builds context correctly using collect_previous_task_context.""" - options = Chat(**sample_chat_data) - - # Create actual TaskLock with context data - task_lock = TaskLock( - id="test_task_123", queue=AsyncMock(), human_input={} - ) - task_lock.conversation_history = [ - {"role": "user", "content": "Create a Python script"}, - {"role": "assistant", "content": "Script created successfully"}, - ] - task_lock.last_task_result = "def hello(): print('Hello World')" - task_lock.last_task_summary = "Python Hello World Script" - - # Create some files in working directory - working_dir = temp_dir / "test_project" - working_dir.mkdir() - (working_dir / "script.py").write_text( - "def hello(): print('Hello World')" - ) - - # Test the context building directly - # build_conversation_context only processes assistant and task_result roles - context = build_conversation_context(task_lock) - - # Verify context includes conversation history header - assert "=== CONVERSATION HISTORY ===" in context - # assistant entries are included - assert "Script created successfully" in context - - @pytest.mark.asyncio - async def test_step_solve_new_task_state_context_collection( - self, sample_chat_data, mock_request, temp_dir - ): - """Test step_solve correctly collects context in new_task_state action.""" - Chat(**sample_chat_data) - working_dir = temp_dir / "project" - working_dir.mkdir() - - # Create files that should be included in context - (working_dir / "main.py").write_text("print('main')") - (working_dir / "config.json").write_text('{"version": "1.0"}') - - # Mock file_save_path to return our temp directory - with patch.object( - Chat, "file_save_path", return_value=str(working_dir) - ): - # Test collect_previous_task_context directly with the scenario - result = collect_previous_task_context( - working_directory=str(working_dir), - previous_task_content="Create project structure", - previous_task_result="Project files created successfully", - previous_summary="Project Setup Task", - ) - - # Verify all expected elements are present - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "Previous Task:" in result - assert "Create project structure" in result - assert "Previous Task Summary:" in result - assert "Project Setup Task" in result - assert "Previous Task Result:" in result - assert "Project files created successfully" in result - assert "Generated Files from Previous Task:" in result - assert "main.py" in result - assert "config.json" in result - assert "=== END OF PREVIOUS TASK CONTEXT ===" in result - - @pytest.mark.asyncio - async def test_step_solve_end_action_context_collection( - self, sample_chat_data, mock_request, temp_dir - ): - """Test step_solve correctly collects and saves context in end action.""" - Chat(**sample_chat_data) - working_dir = temp_dir / "finished_project" - working_dir.mkdir() - - # Create output files - (working_dir / "output.txt").write_text("Final output") - (working_dir / "report.md").write_text("# Task Report") - - # Create actual TaskLock - task_lock = TaskLock( - id="test_end_task", queue=AsyncMock(), human_input={} - ) - task_lock.last_task_summary = "Final Task Summary" - - # Mock file_save_path - with patch.object( - Chat, "file_save_path", return_value=str(working_dir) - ): - # Test the context collection for end action scenario - task_content = "Generate final report" - task_result = "Report generated successfully with output files" - - context = collect_previous_task_context( - working_directory=str(working_dir), - previous_task_content=task_content, - previous_task_result=task_result, - previous_summary=task_lock.last_task_summary, - ) - - # Verify context structure for end action - assert "=== CONTEXT FROM PREVIOUS TASK ===" in context - assert "Generate final report" in context - assert "Report generated successfully with output files" in context - assert "Final Task Summary" in context - assert "output.txt" in context - assert "report.md" in context - - # Test that context can be added to conversation history - task_lock.add_conversation("task_result", context) - assert len(task_lock.conversation_history) == 1 - assert task_lock.conversation_history[0]["role"] == "task_result" - assert task_lock.conversation_history[0]["content"] == context - - @pytest.mark.asyncio - @pytest.mark.skip(reason="Gets Stuck for some reason.") - async def test_step_solve_basic_workflow( - self, sample_chat_data, mock_request, mock_task_lock - ): - """Test step_solve basic workflow integration.""" - options = Chat(**sample_chat_data) - - # Mock the action queue to return improve action first, then end - mock_task_lock.get_queue = AsyncMock( - side_effect=[ - # First call returns improve action - ActionImproveData( - action=Action.improve, - data=ImprovePayload(question="Test question"), - ), - # Second call returns end action - ActionEndData(action=Action.end), - ] - ) - - mock_workforce = MagicMock() - mock_mcp = MagicMock() - - with ( - patch( - "app.service.chat_service.decomposition.construct_workforce", - return_value=(mock_workforce, mock_mcp), - ), - patch( - "app.service.chat_service.router.question_confirm", - return_value=True, - ), - patch( - "app.service.chat_service.router.summary_task", - return_value="Test Summary", - ), - ): - mock_workforce.eigent_make_sub_tasks.return_value = [] - - # Convert async generator to list - responses = [] - async for response in step_solve( - options, mock_request, mock_task_lock - ): - responses.append(response) - # Break after a few responses to avoid infinite loop - if len(responses) > 10: - break - - # Should have received some responses - assert len(responses) > 0 - - @pytest.mark.asyncio - async def test_step_solve_with_disconnected_request( - self, sample_chat_data, mock_request, mock_task_lock - ): - """Test step_solve handles disconnected request.""" - options = Chat(**sample_chat_data) - mock_request.is_disconnected = AsyncMock(return_value=True) - - mock_workforce = MagicMock() - - with patch( - "app.service.chat_service.construct_workforce", - return_value=(mock_workforce, MagicMock()), - ): - # Should exit immediately if request is disconnected - responses = [] - async for response in step_solve( - options, mock_request, mock_task_lock - ): - responses.append(response) - - # Should not have any responses due to immediate disconnection - assert len(responses) == 0 - # Note: Workforce might not be created/stopped if request is immediately disconnected - - @pytest.mark.asyncio - @pytest.mark.skip(reason="Gets Stuck for some reason.") - async def test_step_solve_error_handling( - self, sample_chat_data, mock_request, mock_task_lock - ): - """Test step_solve handles errors gracefully.""" - options = Chat(**sample_chat_data) - - # Mock get_queue to raise an exception - mock_task_lock.get_queue = AsyncMock( - side_effect=Exception("Queue error") - ) - - responses = [] - async for response in step_solve( - options, mock_request, mock_task_lock - ): - responses.append(response) - break # Exit after first iteration - - # Should handle the error and exit gracefully - assert len(responses) == 0 - - -@pytest.mark.model_backend -class TestChatServiceWithLLM: - """Tests that require LLM backend (marked for selective running).""" - - @pytest.mark.asyncio - async def test_construct_workforce_with_real_agents( - self, sample_chat_data - ): - """Test construct_workforce with real agent creation.""" - Chat(**sample_chat_data) - - # This test would create real agents and workforce - # Marked as model_backend test for selective execution - assert True # Placeholder - - @pytest.mark.very_slow - async def test_full_chat_workflow_integration( - self, sample_chat_data, mock_request - ): - """Test complete chat workflow with real components (very slow test).""" - Chat(**sample_chat_data) - - # This test would run the complete chat workflow - # Marked as very_slow for execution only in full test mode - assert True # Placeholder - - -@pytest.mark.unit -class TestChatServiceErrorCases: - """Test error cases and edge conditions for chat service.""" - - def test_collect_previous_task_context_os_walk_exception(self, temp_dir): - """Test collect_previous_task_context handles os.walk exceptions.""" - working_directory = str(temp_dir) - - with patch("os.walk", side_effect=OSError("Permission denied")): - with patch("app.utils.file_utils.logger") as mock_logger: - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test task", - previous_task_result="Test result", - previous_summary="Test summary", - ) - - # Should still include basic context - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "Test task" in result - assert "Test result" in result - assert "Test summary" in result - - # Should not include file listing - assert "Generated Files from Previous Task:" not in result - - # Warning is logged by file_utils.list_files - mock_logger.warning.assert_called_once() - - def test_collect_previous_task_context_abspath_used(self, temp_dir): - """Test collect_previous_task_context uses absolute paths for files.""" - working_directory = str(temp_dir) - - # Create a test file - (temp_dir / "test.txt").write_text("test content") - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test task", - previous_task_result="Test result", - previous_summary="Test summary", - ) - - # Should include absolute path for the file - assert "=== CONTEXT FROM PREVIOUS TASK ===" in result - assert "test.txt" in result - - def test_build_conversation_context_missing_attributes(self, temp_dir): - """Test build_conversation_context handles missing attributes gracefully.""" - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = None - - result = build_conversation_context(task_lock) - - # Should handle missing attributes gracefully - assert result == "" - - def test_build_conversation_context_empty_conversation(self): - """Test build_conversation_context returns empty for empty conversation.""" - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [] - - # Should return empty string for empty conversation history - result = build_conversation_context(task_lock) - assert result == "" - - def test_collect_previous_task_context_unicode_handling(self, temp_dir): - """Test collect_previous_task_context handles unicode content correctly.""" - working_directory = str(temp_dir) - - # Create files with unicode content - (temp_dir / "unicode_file.txt").write_text( - "Unicode content: 🐍 Python ñáéíóú", encoding="utf-8" - ) - - unicode_task_content = ( - "Create files with unicode: 🔥 emojis and ñáéíóú accents" - ) - unicode_result = "Files created successfully with unicode: ✅ done" - unicode_summary = "Unicode Task: 📝 file creation" - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content=unicode_task_content, - previous_task_result=unicode_result, - previous_summary=unicode_summary, - ) - - # Should handle unicode correctly - assert "🔥 emojis" in result - assert "ñáéíóú accents" in result - assert "✅ done" in result - assert "📝 file creation" in result - assert "unicode_file.txt" in result - - def test_collect_previous_task_context_very_long_content(self, temp_dir): - """Test collect_previous_task_context handles very long content.""" - working_directory = str(temp_dir) - - # Create very long content strings - long_content = "Very long task content. " * 1000 # ~25KB - long_result = "Very long task result. " * 1000 # ~23KB - long_summary = "Very long summary. " * 100 # ~1.8KB - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content=long_content, - previous_task_result=long_result, - previous_summary=long_summary, - ) - - # Should handle long content without issues - assert len(result) > 49000 # Should be quite long - assert "Very long task content." in result - assert "Very long task result." in result - assert "Very long summary." in result - - def test_collect_previous_task_context_many_files(self, temp_dir): - """Test collect_previous_task_context performance with many files.""" - working_directory = str(temp_dir) - - # Create many files to test performance - for i in range(100): - (temp_dir / f"file_{i:03d}.txt").write_text(f"Content {i}") - - # Create subdirectories with files - for dir_i in range(10): - sub_dir = temp_dir / f"subdir_{dir_i}" - sub_dir.mkdir() - for file_i in range(10): - (sub_dir / f"subfile_{file_i}.txt").write_text( - f"Sub content {dir_i}-{file_i}" - ) - - import time - - start_time = time.time() - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test many files", - previous_task_result="Many files processed", - previous_summary="Performance test", - ) - - end_time = time.time() - execution_time = end_time - start_time - - # Should complete in reasonable time (less than 1 second for 200 files) - assert execution_time < 1.0 - - # Should list all files - assert "Generated Files from Previous Task:" in result - # Count number of file entries - file_lines = [line for line in result.split("\n") if " - " in line] - assert len(file_lines) == 200 # 100 main files + 100 subfiles - - def test_collect_previous_task_context_special_characters_in_filenames( - self, temp_dir - ): - """Test collect_previous_task_context handles special characters in filenames.""" - working_directory = str(temp_dir) - - # Create files with special characters (that are valid in filenames) - try: - (temp_dir / "file with spaces.txt").write_text("content") - (temp_dir / "file-with-dashes.txt").write_text("content") - (temp_dir / "file_with_underscores.txt").write_text("content") - (temp_dir / "file.with.dots.txt").write_text("content") - except OSError: - # Skip if filesystem doesn't support these characters - pytest.skip( - "Filesystem doesn't support special characters in filenames" - ) - - result = collect_previous_task_context( - working_directory=working_directory, - previous_task_content="Test special chars", - previous_task_result="Files created", - previous_summary="", - ) - - # Should list files with special characters - assert "file with spaces.txt" in result - assert "file-with-dashes.txt" in result - assert "file_with_underscores.txt" in result - assert "file.with.dots.txt" in result - - @pytest.mark.asyncio - async def test_question_confirm_agent_error(self, mock_camel_agent): - """Test question_confirm when agent raises error.""" - mock_camel_agent.step.side_effect = Exception("Agent error") - - task_lock = MagicMock(spec=TaskLock) - task_lock.conversation_history = [] - task_lock.question_agent = mock_camel_agent - - options = MagicMock() - with pytest.raises(Exception, match="Agent error"): - await question_confirm("test question", options, task_lock) - - @pytest.mark.asyncio - async def test_summary_task_agent_error(self, mock_camel_agent): - """Test summary_task when agent raises error.""" - mock_camel_agent.step.side_effect = Exception("Summary error") - - task = Task(content="Test task", id="test") - options = MagicMock() - - with ( - patch( - "app.agent.factory.task_summary._create_summary_agent", - return_value=mock_camel_agent, - ), - pytest.raises(Exception, match="Summary error"), - ): - await summary_task(task, options) - - @pytest.mark.asyncio - async def test_construct_workforce_agent_creation_error( - self, sample_chat_data, mock_task_lock - ): - """Test construct_workforce when agent creation fails.""" - options = Chat(**sample_chat_data) - - with ( - patch( - "app.agent.toolkit.human_toolkit.get_task_lock", - return_value=mock_task_lock, - ), - patch( - "app.service.chat_service.lifecycle.agent_model", - side_effect=Exception("Agent creation failed"), - ), - patch( - "app.agent.factory.workforce_agents.agent_model", - side_effect=Exception("Agent creation failed"), - ), - patch( - "app.agent.factory.developer.agent_model", - side_effect=Exception("Agent creation failed"), - ), - patch( - "app.agent.factory.browser.agent_model", - side_effect=Exception("Agent creation failed"), - ), - patch( - "app.agent.factory.document.agent_model", - side_effect=Exception("Agent creation failed"), - ), - patch( - "app.agent.factory.multi_modal.agent_model", - side_effect=Exception("Agent creation failed"), - ), - ): - with pytest.raises(Exception, match="Agent creation failed"): - await construct_workforce(options) - - @pytest.mark.asyncio - async def test_new_agent_model_with_invalid_tools(self, sample_chat_data): - """Test new_agent_model with invalid tool configuration.""" - options = Chat(**sample_chat_data) - agent_data = NewAgent( - name="InvalidAgent", - description="Agent with invalid tools", - tools=["nonexistent_tool"], - mcp_tools=None, - env_path=".env", - ) - - with patch( - "app.service.chat_service.lifecycle.get_toolkits", - side_effect=Exception("Invalid tool"), - ): - with pytest.raises(Exception, match="Invalid tool"): - await new_agent_model(agent_data, options) - - def test_format_agent_description_with_none_values(self): - """Test format_agent_description handles empty values gracefully.""" - from app.service.task import ActionNewAgent - - # Test with ActionNewAgent that might have empty values - agent_data = ActionNewAgent( - name="TestAgent", - description="", # Empty string instead of None - tools=[], - mcp_tools=None, # Should be None instead of empty list - ) - - result = format_agent_description(agent_data) - - assert "TestAgent:" in result - assert "A specialized agent" in result # Default description - - def test_tree_sub_tasks_with_none_content(self): - """Test tree_sub_tasks handles tasks with empty content.""" - task1 = Task(content="Valid Task", id="task_1") - task1.state = TaskState.OPEN - - # Create task with empty content (edge case) - task2 = Task(content="", id="task_2") # Empty string instead of None - task2.state = TaskState.OPEN - - # Should handle empty content gracefully - result = tree_sub_tasks([task1, task2]) - - # Should filter out empty content tasks - assert len(result) <= 1 diff --git a/backend/tests/app/utils/test_context.py b/backend/tests/app/utils/test_context.py new file mode 100644 index 000000000..6bd35fe9b --- /dev/null +++ b/backend/tests/app/utils/test_context.py @@ -0,0 +1,400 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from app.service.task import TaskLock +from app.utils.context import ( + build_conversation_context, + collect_previous_task_context, + format_task_context, +) + +pytestmark = pytest.mark.unit + + +# --- format_task_context --- + + +def test_format_task_context_with_working_directory_and_files(temp_dir): + (temp_dir / "output.txt").write_text("content") + task_data = { + "task_content": "Create file", + "task_result": "Done", + "working_directory": str(temp_dir), + } + result = format_task_context(task_data, skip_files=False) + assert "Previous Task: Create file" in result + assert "output.txt" in result + assert "Generated Files from Previous Task:" in result + + +def test_format_task_context_skip_files(temp_dir): + task_data = { + "task_content": "Task", + "task_result": "Result", + "working_directory": str(temp_dir), + } + result = format_task_context(task_data, skip_files=True) + assert "Generated Files from Previous Task:" not in result + + +# --- collect_previous_task_context --- + + +def test_collect_previous_task_context_basic(temp_dir): + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Create a Python script", + previous_task_result="Successfully created script.py", + previous_summary="Python Script Creation Task", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "Previous Task:" in result + assert "Create a Python script" in result + assert "Previous Task Summary:" in result + assert "Python Script Creation Task" in result + assert "Previous Task Result:" in result + assert "Successfully created script.py" in result + assert "=== END OF PREVIOUS TASK CONTEXT ===" in result + + +def test_collect_previous_task_context_with_generated_files(temp_dir): + (temp_dir / "script.py").write_text("print('Hello World')") + (temp_dir / "config.json").write_text('{"test": true}') + (temp_dir / "README.md").write_text("# Test Project") + sub_dir = temp_dir / "utils" + sub_dir.mkdir() + (sub_dir / "helper.py").write_text("def helper(): pass") + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Create project files", + previous_task_result="Files created successfully", + previous_summary="", + ) + assert "Generated Files from Previous Task:" in result + assert "script.py" in result + assert "config.json" in result + assert "README.md" in result + assert "utils/helper.py" in result or "utils\\helper.py" in result + + lines = result.split("\n") + file_lines = [ + line.strip() for line in lines if line.strip().startswith("- ") + ] + assert len(file_lines) == 4 + + +def test_collect_previous_task_context_filters_hidden_files(temp_dir): + (temp_dir / "visible.py").write_text("# Visible file") + (temp_dir / ".hidden_file").write_text("hidden content") + (temp_dir / ".env").write_text("SECRET=hidden") + hidden_dir = temp_dir / ".hidden_dir" + hidden_dir.mkdir() + (hidden_dir / "file.txt").write_text("in hidden dir") + cache_dir = temp_dir / "__pycache__" + cache_dir.mkdir() + (cache_dir / "module.pyc").write_text("compiled") + node_modules = temp_dir / "node_modules" + node_modules.mkdir() + (node_modules / "package").mkdir() + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test filtering", + previous_task_result="Files filtered", + previous_summary="", + ) + assert "visible.py" in result + assert ".hidden_file" not in result + assert ".env" not in result + assert "__pycache__" not in result + assert "node_modules" not in result + assert ".hidden_dir" not in result + + +def test_collect_previous_task_context_filters_temp_files(temp_dir): + (temp_dir / "main.py").write_text("# Main file") + (temp_dir / "temp.tmp").write_text("temporary") + (temp_dir / "compiled.pyc").write_text("compiled python") + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test temp filtering", + previous_task_result="Temp files filtered", + previous_summary="", + ) + assert "main.py" in result + assert "temp.tmp" not in result + assert "compiled.pyc" not in result + + +def test_collect_previous_task_context_nonexistent_directory(): + result = collect_previous_task_context( + working_directory="/nonexistent/directory", + previous_task_content="Test task", + previous_task_result="Test result", + previous_summary="Test summary", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "Test task" in result + assert "Test result" in result + assert "Test summary" in result + assert "Generated Files from Previous Task:" not in result + + +def test_collect_previous_task_context_empty_inputs(temp_dir): + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="", + previous_task_result="", + previous_summary="", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "=== END OF PREVIOUS TASK CONTEXT ===" in result + assert "Previous Task:" not in result + assert "Previous Task Summary:" not in result + assert "Previous Task Result:" not in result + + +def test_collect_previous_task_context_only_summary(temp_dir): + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="", + previous_task_result="", + previous_summary="Only summary provided", + ) + assert "Previous Task Summary:" in result + assert "Only summary provided" in result + assert "Previous Task:" not in result + assert "Previous Task Result:" not in result + + +@patch("app.utils.file_utils.logger") +def test_collect_previous_task_context_file_system_error( + mock_logger, temp_dir +): + with patch("os.walk", side_effect=PermissionError("Access denied")): + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test task", + previous_task_result="Test result", + previous_summary="Test summary", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "Test task" in result + assert "Generated Files from Previous Task:" not in result + mock_logger.warning.assert_called_once() + + +def test_collect_previous_task_context_relative_paths(temp_dir): + deep_dir = temp_dir / "level1" / "level2" / "level3" + deep_dir.mkdir(parents=True) + (deep_dir / "deep_file.txt").write_text("deep content") + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test relative paths", + previous_task_result="Paths converted", + previous_summary="", + ) + expected_path = "level1/level2/level3/deep_file.txt" + windows_path = "level1\\level2\\level3\\deep_file.txt" + assert expected_path in result or windows_path in result + + +def test_collect_previous_task_context_os_walk_exception(temp_dir): + with patch("os.walk", side_effect=OSError("Permission denied")): + with patch("app.utils.file_utils.logger") as mock_logger: + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test task", + previous_task_result="Test result", + previous_summary="Test summary", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "Test task" in result + assert "Test result" in result + assert "Test summary" in result + assert "Generated Files from Previous Task:" not in result + mock_logger.warning.assert_called_once() + + +def test_collect_previous_task_context_abspath_used(temp_dir): + (temp_dir / "test.txt").write_text("test content") + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test task", + previous_task_result="Test result", + previous_summary="Test summary", + ) + assert "=== CONTEXT FROM PREVIOUS TASK ===" in result + assert "test.txt" in result + + +def test_collect_previous_task_context_unicode_handling(temp_dir): + (temp_dir / "unicode_file.txt").write_text( + "Unicode content: 🐍 Python ñáéíóú", encoding="utf-8" + ) + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Create files with unicode: 🔥 emojis and ñáéíóú accents", + previous_task_result="Files created successfully with unicode: ✅ done", + previous_summary="Unicode Task: 📝 file creation", + ) + assert "🔥 emojis" in result + assert "ñáéíóú accents" in result + assert "✅ done" in result + assert "📝 file creation" in result + assert "unicode_file.txt" in result + + +def test_collect_previous_task_context_very_long_content(temp_dir): + long_content = "Very long task content. " * 1000 + long_result = "Very long task result. " * 1000 + long_summary = "Very long summary. " * 100 + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content=long_content, + previous_task_result=long_result, + previous_summary=long_summary, + ) + assert len(result) > 49000 + assert "Very long task content." in result + assert "Very long task result." in result + assert "Very long summary." in result + + +def test_collect_previous_task_context_many_files(temp_dir): + for i in range(100): + (temp_dir / f"file_{i:03d}.txt").write_text(f"Content {i}") + for dir_i in range(10): + sub_dir = temp_dir / f"subdir_{dir_i}" + sub_dir.mkdir() + for file_i in range(10): + (sub_dir / f"subfile_{file_i}.txt").write_text( + f"Sub content {dir_i}-{file_i}" + ) + + start_time = time.time() + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test many files", + previous_task_result="Many files processed", + previous_summary="Performance test", + ) + execution_time = time.time() - start_time + assert execution_time < 1.0 + assert "Generated Files from Previous Task:" in result + file_lines = [line for line in result.split("\n") if " - " in line] + assert len(file_lines) == 200 + + +def test_collect_previous_task_context_special_characters_in_filenames( + temp_dir, +): + try: + (temp_dir / "file with spaces.txt").write_text("content") + (temp_dir / "file-with-dashes.txt").write_text("content") + (temp_dir / "file_with_underscores.txt").write_text("content") + (temp_dir / "file.with.dots.txt").write_text("content") + except OSError: + pytest.skip( + "Filesystem doesn't support special characters in filenames" + ) + + result = collect_previous_task_context( + working_directory=str(temp_dir), + previous_task_content="Test special chars", + previous_task_result="Files created", + previous_summary="", + ) + assert "file with spaces.txt" in result + assert "file-with-dashes.txt" in result + assert "file_with_underscores.txt" in result + assert "file.with.dots.txt" in result + + +# --- build_conversation_context --- + + +def test_build_conversation_context_basic(): + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [ + { + "role": "assistant", + "content": "I will create a Python script for you", + }, + ] + result = build_conversation_context(task_lock) + assert "=== CONVERSATION HISTORY ===" in result + assert "I will create a Python script for you" in result + + +def test_build_conversation_context_empty_history(): + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [] + result = build_conversation_context(task_lock) + assert result == "" + + +def test_build_conversation_context_task_result_role(): + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [ + { + "role": "task_result", + "content": "Full task context from previous task", + }, + { + "role": "assistant", + "content": "Task completed successfully", + }, + ] + result = build_conversation_context(task_lock) + assert "Full task context from previous task" in result + assert "Task completed successfully" in result + + +def test_build_conversation_context_with_assistant_entries(): + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [ + { + "role": "assistant", + "content": "Task completed with output.txt", + }, + ] + result = build_conversation_context(task_lock) + assert "=== CONVERSATION HISTORY ===" in result + assert "Task completed with output.txt" in result + + +def test_build_conversation_context_missing_attributes(): + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = None + result = build_conversation_context(task_lock) + assert result == "" + + +def test_build_conversation_context_empty_conversation(): + task_lock = MagicMock(spec=TaskLock) + task_lock.conversation_history = [] + result = build_conversation_context(task_lock) + assert result == "" From a824471139a27209f5622424f6bf72d4405fb58d Mon Sep 17 00:00:00 2001 From: bytecii Date: Thu, 5 Mar 2026 14:05:40 -0800 Subject: [PATCH 4/4] Update --- backend/app/agent/factory/task_summary.py | 2 -- backend/app/agent/factory/workforce_agents.py | 2 -- backend/app/service/chat_service/decomposition.py | 2 -- backend/app/service/chat_service/handlers.py | 2 -- backend/app/service/chat_service/lifecycle.py | 2 -- backend/app/service/chat_service/question_router.py | 2 -- backend/app/utils/context.py | 2 -- 7 files changed, 14 deletions(-) diff --git a/backend/app/agent/factory/task_summary.py b/backend/app/agent/factory/task_summary.py index ad579859c..415ee58b4 100644 --- a/backend/app/agent/factory/task_summary.py +++ b/backend/app/agent/factory/task_summary.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from __future__ import annotations - import logging from camel.tasks import Task diff --git a/backend/app/agent/factory/workforce_agents.py b/backend/app/agent/factory/workforce_agents.py index b2997c9f7..c0b63dcbb 100644 --- a/backend/app/agent/factory/workforce_agents.py +++ b/backend/app/agent/factory/workforce_agents.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Factory functions for workforce-internal agents (coordinator, task, worker).""" - from __future__ import annotations import datetime diff --git a/backend/app/service/chat_service/decomposition.py b/backend/app/service/chat_service/decomposition.py index 36e7b4d67..f1f68fdb7 100644 --- a/backend/app/service/chat_service/decomposition.py +++ b/backend/app/service/chat_service/decomposition.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from __future__ import annotations - import asyncio import logging from pathlib import Path diff --git a/backend/app/service/chat_service/handlers.py b/backend/app/service/chat_service/handlers.py index f1640bf5b..e04d403e8 100644 --- a/backend/app/service/chat_service/handlers.py +++ b/backend/app/service/chat_service/handlers.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from __future__ import annotations - import asyncio import logging diff --git a/backend/app/service/chat_service/lifecycle.py b/backend/app/service/chat_service/lifecycle.py index a77d9af65..c838de789 100644 --- a/backend/app/service/chat_service/lifecycle.py +++ b/backend/app/service/chat_service/lifecycle.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from __future__ import annotations - import asyncio import datetime import logging diff --git a/backend/app/service/chat_service/question_router.py b/backend/app/service/chat_service/question_router.py index 6a970f0d2..5e47a9372 100644 --- a/backend/app/service/chat_service/question_router.py +++ b/backend/app/service/chat_service/question_router.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from __future__ import annotations - import asyncio import logging import time diff --git a/backend/app/utils/context.py b/backend/app/utils/context.py index dd95a0b75..5d12965f1 100644 --- a/backend/app/utils/context.py +++ b/backend/app/utils/context.py @@ -12,8 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -"""Shared context-building utilities for conversation history and task data.""" - import logging from app.service.task import TaskLock