diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py
index 264103bba..36097bf56 100644
--- a/backend/app/agent/__init__.py
+++ b/backend/app/agent/__init__.py
@@ -15,13 +15,17 @@
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_agent,
+ question_confirm,
social_media_agent,
- task_summary_agent,
+ summary_subtasks_result,
+ summary_task,
)
from app.agent.listen_chat_agent import ListenChatAgent
from app.agent.tools import get_mcp_tools, get_toolkits
@@ -32,11 +36,15 @@
"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_agent",
+ "question_confirm",
"social_media_agent",
- "task_summary_agent",
+ "summary_subtasks_result",
+ "summary_task",
]
diff --git a/backend/app/agent/factory/__init__.py b/backend/app/agent/factory/__init__.py
index ef74a818d..dbfa08769 100644
--- a/backend/app/agent/factory/__init__.py
+++ b/backend/app/agent/factory/__init__.py
@@ -17,17 +17,29 @@
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
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,
+)
+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_agent",
+ "question_confirm",
"social_media_agent",
- "task_summary_agent",
+ "summary_subtasks_result",
+ "summary_task",
]
diff --git a/backend/app/agent/factory/question_confirm.py b/backend/app/agent/factory/question_confirm.py
index 3461f5238..982771dbb 100644
--- a/backend/app/agent/factory/question_confirm.py
+++ b/backend/app/agent/factory/question_confirm.py
@@ -12,15 +12,112 @@
# 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.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
+from app.utils.context import build_conversation_context
+
+if TYPE_CHECKING:
+ from app.service.task import TaskLock
+
+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),
options,
)
+
+
+async def question_confirm(
+ prompt: str, options: Chat, task_lock: TaskLock
+) -> bool:
+ """Classify whether a user query is a complex task or simple question.
+
+ Creates and caches the question agent on task_lock.question_agent
+ for reuse across multi-turn conversations.
+
+ 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)
+
+ agent = task_lock.question_agent
+
+ context_prompt = build_conversation_context(
+ task_lock, header="=== Previous Conversation ==="
+ )
+
+ full_prompt = QUESTION_CONFIRM_PROMPT.format(
+ context_prompt=context_prompt, user_query=prompt
+ )
+
+ 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 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/task_summary.py b/backend/app/agent/factory/task_summary.py
index e0b05afaf..415ee58b4 100644
--- a/backend/app/agent/factory/task_summary.py
+++ b/backend/app/agent/factory/task_summary.py
@@ -12,14 +12,115 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
+import logging
+
+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
+logger = logging.getLogger(__name__)
+
-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,
options,
)
+
+
+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)
+ 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(task: Task, options: Chat) -> str:
+ """Summarize the aggregated results from all subtasks.
+
+ Args:
+ 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"
+ subtasks_info += f"Description: {subtask.content}\n"
+ subtasks_info += f"Result: {subtask.result or 'No result'}\n"
+ subtasks_info += "---\n"
+
+ prompt = SUBTASKS_SUMMARY_PROMPT.format(
+ task_content=task.content, subtasks_info=subtasks_info
+ )
+
+ 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:
+ 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:
+ 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..c0b63dcbb
--- /dev/null
+++ b/backend/app/agent/factory/workforce_agents.py
@@ -0,0 +1,118 @@
+# ========= 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 datetime
+import platform
+from typing import TYPE_CHECKING
+
+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
+from app.model.chat import Chat
+from app.service.task import Agents
+
+if TYPE_CHECKING:
+ 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]:
+ """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: (
+ "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()
+ ]
+
+
+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,
+ "You are a helpful assistant.\n" + _env_prompt(working_directory),
+ 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/agent/prompt.py b/backend/app/agent/prompt.py
index 77bd9e2fe..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 \
@@ -699,6 +736,68 @@
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:
+"""
+
+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.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..fa7455a0c
--- /dev/null
+++ b/backend/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/app/service/chat_service/decomposition.py b/backend/app/service/chat_service/decomposition.py
new file mode 100644
index 000000000..f1f68fdb7
--- /dev/null
+++ b/backend/app/service/chat_service/decomposition.py
@@ -0,0 +1,258 @@
+# ========= 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 logging
+from pathlib import Path
+from typing import Any
+
+from camel.tasks import Task
+
+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,
+ format_agent_description,
+ new_agent_model,
+ tree_sub_tasks,
+)
+from app.service.chat_service.types import LoopControl, StepSolveState
+from app.service.task import (
+ ActionDecomposeProgressData,
+ ActionDecomposeTextData,
+ set_current_task_id,
+)
+from app.utils.context import build_conversation_context
+
+logger = logging.getLogger(__name__)
+
+
+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
+ try:
+ new_summary = await asyncio.wait_for(
+ summary_task(state.camel_task, state.options),
+ 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."""
+ 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_conversation_context(state.task_lock)
+
+ # 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/handlers.py b/backend/app/service/chat_service/handlers.py
new file mode 100644
index 000000000..e04d403e8
--- /dev/null
+++ b/backend/app/service/chat_service/handlers.py
@@ -0,0 +1,450 @@
+# ========= 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 logging
+
+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.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
+
+logger = logging.getLogger(__name__)
+
+
+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
+
+
+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:
+ 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]:
+ logger.warning(
+ f"Client disconnected for project {state.options.project_id}"
+ )
+ _stop_workforce(state)
+ state.task_lock.status = Status.done
+ return [], LoopControl.BREAK
+
+
+async def handle_disconnect_cleanup(state: StepSolveState) -> None:
+ try:
+ await delete_task_lock(state.task_lock.id)
+ 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]:
+ assert state.camel_task is not None
+ update_tasks_map = {item.id: item for item in item.data.task}
+ 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)
+ update_sub_tasks(state.camel_task.subtasks, update_tasks_map)
+ new_tasks = add_sub_tasks(state.camel_task, item.data.task)
+ state.sub_tasks.extend(new_tasks)
+ 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]:
+ if state.camel_task is None and state.workforce is None:
+ logger.error(
+ f"Cannot add task: not initialized for {state.options.project_id}"
+ )
+ return [
+ _sse_error(
+ "Cannot add task: task not initialized. Please start a task first."
+ )
+ ], LoopControl.CONTINUE
+
+ assert state.camel_task is not None
+ if state.workforce is None:
+ logger.error(
+ f"Cannot add task: workforce not initialized for {state.options.project_id}"
+ )
+ return [
+ _sse_error(
+ "Workforce not initialized. Please start the task first."
+ )
+ ], LoopControl.CONTINUE
+
+ state.workforce.add_task(item.content, item.task_id, item.additional_info)
+ 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]:
+ if state.workforce is None:
+ logger.error(
+ f"Cannot remove task: workforce not initialized for {state.options.project_id}"
+ )
+ return [
+ _sse_error(
+ "Workforce not initialized. Please start the task first."
+ )
+ ], LoopControl.CONTINUE
+
+ state.workforce.remove_task(item.task_id)
+ 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]:
+ logger.info(f"SKIP_TASK received for project {state.options.project_id}")
+
+ if state.task_lock.status == Status.done:
+ logger.warning("SKIP_TASK ignored: task already done")
+ return [], LoopControl.CONTINUE
+
+ if (
+ state.workforce is not None
+ and item.project_id == state.options.project_id
+ ):
+ _stop_workforce(state, force=True)
+ state.workforce = None
+
+ end_message = "Task stoppedTask stopped by user"
+ _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]:
+ is_exceeded, total_length = check_conversation_history_length(
+ state.task_lock
+ )
+ if is_exceeded:
+ 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,
+ },
+ )
+ ], LoopControl.CONTINUE
+
+ if state.workforce is not None:
+ if state.workforce._state.name == "PAUSED":
+ state.workforce.resume()
+ return [], LoopControl.CONTINUE
+ else:
+ 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 [], LoopControl.NORMAL
+
+
+def handle_task_state(
+ state: StepSolveState, item
+) -> tuple[list[str], LoopControl]:
+ 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]:
+ logger.info(
+ f"END received for project {state.options.project_id}, task {state.options.task_id}"
+ )
+
+ if state.task_lock.status == Status.done:
+ logger.warning("END ignored: task already done")
+ return [], LoopControl.CONTINUE
+
+ if state.camel_task is None:
+ logger.warning(
+ f"END with camel_task=None for {state.options.project_id}"
+ )
+ 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
+ )
+
+ _finalize_task(state, final_result)
+
+ if state.workforce is not None:
+ state.workforce.stop_gracefully()
+ state.workforce = None
+ logger.info(
+ f"Workforce stopped for project {state.options.project_id}"
+ )
+ else:
+ logger.warning(
+ f"Workforce already None at end for {state.options.project_id}"
+ )
+
+ if state.task_lock.question_agent is not None:
+ state.task_lock.question_agent.reset()
+
+ return [sse_json("end", final_result)], LoopControl.NORMAL
+
+
+def handle_supplement(
+ state: StepSolveState, item
+) -> tuple[list[str], LoopControl]:
+ if state.camel_task is None:
+ logger.warning(
+ f"SUPPLEMENT with camel_task=None for {state.options.project_id}"
+ )
+ 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)}",
+ )
+ )
+ 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 [], LoopControl.NORMAL
+
+
+def handle_budget_not_enough(
+ state: StepSolveState, item
+) -> tuple[list[str], 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]:
+ logger.info(f"STOP received for project {state.options.project_id}")
+ _stop_workforce(state)
+ await delete_task_lock(state.task_lock.id)
+ return [], LoopControl.BREAK
+
+
+def handle_pause(state: StepSolveState, item) -> tuple[list[str], LoopControl]:
+ if state.workforce is not None:
+ state.workforce.pause()
+ logger.info(f"Workforce paused for project {state.options.project_id}")
+ else:
+ logger.warning(
+ f"Cannot pause: workforce is None for {state.options.project_id}"
+ )
+ return [], LoopControl.NORMAL
+
+
+def handle_resume(
+ state: StepSolveState, item
+) -> tuple[list[str], LoopControl]:
+ if state.workforce is not None:
+ state.workforce.resume()
+ logger.info(
+ f"Workforce resumed for project {state.options.project_id}"
+ )
+ else:
+ logger.warning(
+ f"Cannot resume: workforce is None for {state.options.project_id}"
+ )
+ return [], LoopControl.NORMAL
+
+
+async def handle_new_agent(
+ state: StepSolveState, item
+) -> tuple[list[str], 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]:
+ logger.info(f"TIMEOUT for project {state.options.project_id}: {item.data}")
+ return [
+ sse_json(
+ "error",
+ {
+ "message": item.data.get("message", "Task execution timeout"),
+ "type": "timeout",
+ "details": {
+ "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),
+ },
+ },
+ )
+ ], LoopControl.NORMAL
+
+
+def handle_install_mcp(
+ state: StepSolveState, item
+) -> tuple[list[str], LoopControl]:
+ if state.mcp is None:
+ logger.error(
+ f"Cannot install MCP: agent not initialized for {state.options.project_id}"
+ )
+ return [
+ _sse_error(
+ "MCP agent not initialized. Please start a complex task first."
+ )
+ ], LoopControl.CONTINUE
+ task = asyncio.create_task(install_mcp(state.mcp, item))
+ state.task_lock.add_background_task(task)
+ return [], 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..c838de789
--- /dev/null
+++ b/backend/app/service/chat_service/lifecycle.py
@@ -0,0 +1,330 @@
+# ========= 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 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.workforce_agents import (
+ create_coordinator_and_task_agents,
+ 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
+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
+
+logger = logging.getLogger(__name__)
+
+
+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
+) -> ListenChatAgent:
+ 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))
+ 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_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,
+ 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)
+
+ 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
+
+ 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_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(
+ 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..5e47a9372
--- /dev/null
+++ b/backend/app/service/chat_service/question_router.py
@@ -0,0 +1,341 @@
+# ========= 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 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/step_solve.py b/backend/app/service/chat_service/step_solve.py
new file mode 100644
index 000000000..aaa9d2d80
--- /dev/null
+++ b/backend/app/service/chat_service/step_solve.py
@@ -0,0 +1,250 @@
+# ========= 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 logging
+
+from camel.models import ModelProcessingError
+from fastapi import Request
+
+from app.model.chat import Chat, sse_json
+from app.service.chat_service.handlers import (
+ handle_add_task,
+ handle_budget_not_enough,
+ handle_disconnect,
+ handle_disconnect_cleanup,
+ handle_end,
+ handle_install_mcp,
+ handle_new_agent,
+ handle_passthrough_event,
+ 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.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
+
+logger = logging.getLogger(__name__)
+
+
+def _initialize_state(state: StepSolveState) -> None:
+ """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"):
+ 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
+
+ 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/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/app/utils/context.py b/backend/app/utils/context.py
new file mode 100644
index 000000000..5d12965f1
--- /dev/null
+++ b/backend/app/utils/context.py
@@ -0,0 +1,226 @@
+# ========= 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 logging
+
+from app.service.task import TaskLock
+from app.utils.file_utils import list_files
+
+logger = logging.getLogger(__name__)
+
+
+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
diff --git a/backend/tests/app/agent/factory/test_question_confirm.py b/backend/tests/app/agent/factory/test_question_confirm.py
index 77a9bbeb4..74b4268e8 100644
--- a/backend/tests/app/agent/factory/test_question_confirm.py
+++ b/backend/tests/app/agent/factory/test_question_confirm.py
@@ -16,36 +16,129 @@
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
+
+ mock_task_lock = MagicMock(spec=TaskLock)
+ mock_task_lock.conversation_history = []
+ mock_task_lock.question_agent = mock_agent # Already cached
+
+ with patch(f"{_mod}._create_question_agent") as mock_create:
+ result = await question_confirm(
+ "create a file", options, mock_task_lock
+ )
+
+ 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
- result = question_confirm_agent(options)
+ mock_task_lock = MagicMock(spec=TaskLock)
+ mock_task_lock.conversation_history = []
+ mock_task_lock.question_agent = mock_agent
- assert result is mock_agent
- mock_agent_model.assert_called_once()
+ from app.agent.factory.question_confirm import simple_answer
- # 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
+ 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 b6f058116..d84efd159 100644
--- a/backend/tests/app/agent/factory/test_task_summary.py
+++ b/backend/tests/app/agent/factory/test_task_summary.py
@@ -15,37 +15,146 @@
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 = await summary_task(task, options)
+
+ 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)
- result = task_summary_agent(options)
+ task = Task(content="Simple task", id="simple")
+ task.result = "Direct result"
- assert result is mock_agent
- mock_agent_model.assert_called_once()
+ with patch(f"{_mod}._create_summary_agent") as mock_create:
+ result = await get_task_result_with_optional_summary(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
+ 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 ab666f3fd..000000000
--- a/backend/tests/app/service/test_chat_service.py
+++ /dev/null
@@ -1,1297 +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_context_for_workforce,
- 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 TestBuildContextForWorkforce:
- """Test cases for build_context_for_workforce 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
- task_lock = MagicMock(spec=TaskLock)
- task_lock.conversation_history = [
- {
- "role": "assistant",
- "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)
-
- # 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."""
- 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)
-
- # 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."""
- 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",
- },
- ]
- 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)
-
- # 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."""
- task_lock = MagicMock(spec=TaskLock)
- task_lock.conversation_history = [
- {
- "role": "assistant",
- "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)
-
- # 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"
- mock_camel_agent.chat_history = []
-
- result = await question_confirm(mock_camel_agent, "hello")
-
- # 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"
- mock_camel_agent.chat_history = []
-
- result = await question_confirm(
- mock_camel_agent, "Create a web application with authentication"
- )
-
- # 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",
- )
-
- result = await summary_task(mock_camel_agent, task)
-
- 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.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
- ),
- ):
- 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.agent_model") as mock_agent_model,
- patch(
- "app.service.chat_service.get_working_directory",
- return_value="/tmp/test_workdir",
- ),
- patch(
- "app.service.chat_service.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.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.WorkforceMetricsCallback",
- return_value=MagicMock(),
- ),
- ):
- mock_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.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_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)
-
- # 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.construct_workforce",
- return_value=(mock_workforce, mock_mcp),
- ),
- patch(
- "app.service.chat_service.question_confirm_agent"
- ) as mock_question_agent,
- patch(
- "app.service.chat_service.task_summary_agent"
- ) as mock_summary_agent,
- patch(
- "app.service.chat_service.question_confirm", return_value=True
- ),
- patch(
- "app.service.chat_service.summary_task",
- 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
- 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_context_for_workforce_missing_attributes(self, temp_dir):
- """Test build_context_for_workforce handles missing attributes gracefully."""
- # Create task_lock without required attributes
- 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
-
- options = MagicMock()
- options.file_save_path.return_value = str(temp_dir)
-
- result = build_context_for_workforce(task_lock, options)
-
- # 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."""
- 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)
- 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")
-
- with pytest.raises(Exception, match="Agent error"):
- await question_confirm(mock_camel_agent, "test question")
-
- @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")
-
- with pytest.raises(Exception, match="Summary error"):
- await summary_task(mock_camel_agent, task)
-
- @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.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.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 == ""