Merge branch 'main' into fix/task-hub-freeze-on-click

This commit is contained in:
Wendong-Fan 2025-10-20 05:14:29 +08:00 committed by GitHub
commit 910a5ede74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 373 additions and 224 deletions

View file

@ -88,7 +88,11 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
assert isinstance(item, ActionImproveData)
question = item.data
if len(question) < 12 and len(options.attaches) == 0:
confirm = await question_confirm(question_agent, question)
messages, confirm = await question_confirm(
question_agent, question, timeout=8.0, task_id=options.task_id
)
for msg in messages:
yield msg
else:
confirm = True
@ -109,7 +113,21 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
camel_task.additional_info = {Path(file_path).name: file_path for file_path in options.attaches}
sub_tasks = await asyncio.to_thread(workforce.eigent_make_sub_tasks, camel_task)
summary_task_content = await summary_task(summary_task_agent, camel_task)
try:
summary_task_content = await asyncio.wait_for(
summary_task(summary_task_agent, camel_task), timeout=10
)
except asyncio.TimeoutError:
logger.warning(f"summary_task timeout for task {options.task_id}")
# Fallback to a minimal summary to unblock UI
fallback_name = "Task"
content_preview = camel_task.content if hasattr(camel_task, "content") else ""
if content_preview is None:
content_preview = ""
fallback_summary = (
(content_preview[:80] + "...") if len(content_preview) > 80 else content_preview
)
summary_task_content = f"{fallback_name}|{fallback_summary}"
yield to_sub_tasks(camel_task, summary_task_content)
# tracer.stop()
# tracer.save("trace.json")
@ -287,8 +305,27 @@ def add_sub_tasks(camel_task: Task, update_tasks: list[TaskContent]):
)
async def question_confirm(agent: ListenChatAgent, prompt: str) -> str | Literal[True]:
prompt = f"""
async def question_confirm(
agent: ListenChatAgent,
prompt: str,
timeout: float = 8.0,
task_id: str = ""
) -> tuple[list, str | Literal[True]]:
"""
Confirm whether a question requires workforce processing.
Args:
agent: The agent to use for classification
prompt: The user's question
timeout: Timeout in seconds (default: 8.0)
task_id: Task ID for logging
Returns:
Tuple of (messages_to_yield, confirm_result)
- messages_to_yield: List of SSE messages to yield (empty if no timeout)
- confirm_result: True to proceed with workforce, or sse_json for user confirmation
"""
analysis_prompt = f"""
> **Your Role:** You are a highly capable agent. Your primary function is to analyze a user's request and determine the appropriate course of action.
>
> **Your Process:**
@ -303,12 +340,26 @@ async def question_confirm(agent: ListenChatAgent, prompt: str) -> str | Literal
> * **For a Simple Query:** Provide a direct and helpful response.
> * **For a Complex Task:** Your *only* response should be "yes". This will trigger a specialized workforce to handle the task. Do not include any other text, punctuation, or pleasantries.
"""
resp = agent.step(prompt)
logger.info(f"resp: {agent.chat_history}")
if resp.msgs[0].content.lower() != "yes":
return sse_json("wait_confirm", {"content": resp.msgs[0].content})
else:
return True
try:
resp = await asyncio.wait_for(
asyncio.to_thread(agent.step, analysis_prompt),
timeout=timeout
)
logger.info(f"resp: {agent.chat_history}")
if resp.msgs[0].content.lower() != "yes":
return ([], sse_json("wait_confirm", {"content": resp.msgs[0].content}))
else:
return ([], True)
except asyncio.TimeoutError:
logger.warning(f"question_confirm timeout for task {task_id}")
notice = sse_json(
"notice",
{"notice": "Quick classification timed out. Responding directly."}
)
return ([notice], True)
async def summary_task(agent: ListenChatAgent, task: Task) -> str:

View file

@ -449,7 +449,7 @@ class ListenChatAgent(ChatAgent):
# Clone tools and collect toolkits that need registration
cloned_tools, toolkits_to_register = self._clone_tools()
new_agent = ListenChatAgent(
api_task_id=self.api_task_id,
agent_name=self.agent_name,
@ -465,7 +465,6 @@ class ListenChatAgent(ChatAgent):
response_terminators=self.response_terminators,
scheduling_strategy=self.model_backend.scheduling_strategy.__name__,
max_iteration=self.max_iteration,
agent_id=self.agent_id,
stop_event=self.stop_event,
tool_execution_timeout=self.tool_execution_timeout,
mask_tool_output=self.mask_tool_output,
@ -751,6 +750,8 @@ def search_agent(options: Chat):
],
)
# Save reference before registering for toolkits_to_register_agent
web_toolkit_for_agent_registration = web_toolkit_custom
web_toolkit_custom = message_integration.register_toolkits(web_toolkit_custom)
terminal_toolkit = TerminalToolkit(options.task_id, Agents.search_agent, safe_mode=True, clone_current_env=False)
terminal_toolkit = message_integration.register_functions([terminal_toolkit.shell_exec])
@ -815,7 +816,7 @@ The current date is {datetime.date.today()}. For any date-related tasks, you MUS
- **CRITICAL URL POLICY**: You are STRICTLY FORBIDDEN from inventing,
guessing, or constructing URLs yourself. You MUST only use URLs from
trusted sources:
1. URLs returned by search tools (like `search_google` or `search_exa`)
1. URLs returned by search tools (`search_google`)
2. URLs found on webpages you have visited through browser tools
3. URLs provided by the user in their request
Fabricating or guessing URLs is considered a critical error and must
@ -861,8 +862,6 @@ Your approach depends on available search tools:
sites using `browser_type` and submit with `browser_enter`
- **Extract URLs from results**: Only use URLs that appear in the search
results on these websites
- **Alternative Search**: If available, use `search_exa` for additional
results
**Common Browser Operations (both scenarios):**
- **Navigation and Exploration**: Use `browser_visit_page` to open URLs.
@ -899,6 +898,7 @@ Your approach depends on available search tools:
NoteTakingToolkit.toolkit_name(),
TerminalToolkit.toolkit_name(),
],
toolkits_to_register_agent=[web_toolkit_for_agent_registration],
)
@ -930,10 +930,10 @@ async def document_agent(options: Chat):
*terminal_toolkit.get_tools(),
*await GoogleDriveMCPToolkit.get_can_use_tools(options.task_id, options.get_bun_env()),
]
if env("EXA_API_KEY") or options.is_cloud():
search_toolkit = SearchToolkit(options.task_id, Agents.document_agent).search_exa
search_toolkit = message_integration.register_functions([search_toolkit])
tools.extend(search_toolkit)
# if env("EXA_API_KEY") or options.is_cloud():
# search_toolkit = SearchToolkit(options.task_id, Agents.document_agent).search_exa
# search_toolkit = message_integration.register_functions([search_toolkit])
# tools.extend(search_toolkit)
system_message = f"""
<role>
You are a Documentation Specialist, responsible for creating, modifying, and
@ -1162,10 +1162,10 @@ def multi_modal_agent(options: Chat):
audio_analysis_toolkit = message_integration.register_toolkits(audio_analysis_toolkit)
tools.extend(audio_analysis_toolkit.get_tools())
if env("EXA_API_KEY") or options.is_cloud():
search_toolkit = SearchToolkit(options.task_id, Agents.multi_modal_agent).search_exa
search_toolkit = message_integration.register_functions([search_toolkit])
tools.extend(search_toolkit)
# if env("EXA_API_KEY") or options.is_cloud():
# search_toolkit = SearchToolkit(options.task_id, Agents.multi_modal_agent).search_exa
# search_toolkit = message_integration.register_functions([search_toolkit])
# tools.extend(search_toolkit)
system_message = f"""
<role>
@ -1294,8 +1294,8 @@ async def social_medium_agent(options: Chat):
# *DiscordToolkit(options.task_id).get_tools(), # Not supported temporarily
# *GoogleSuiteToolkit(options.task_id).get_tools(), # Not supported temporarily
]
if env("EXA_API_KEY") or options.is_cloud():
tools.append(FunctionTool(SearchToolkit(options.task_id, Agents.social_medium_agent).search_exa))
# if env("EXA_API_KEY") or options.is_cloud():
# tools.append(FunctionTool(SearchToolkit(options.task_id, Agents.social_medium_agent).search_exa))
return agent_model(
Agents.social_medium_agent,
BaseMessage.make_assistant_message(

View file

@ -36,7 +36,6 @@ def _safe_put_queue(task_lock, data):
asyncio.set_event_loop(new_loop)
try:
new_loop.run_until_complete(task_lock.put_queue(data))
logger.debug(f"[listen_toolkit] Successfully sent data to queue using new event loop")
finally:
new_loop.close()
except Exception as e:
@ -96,7 +95,6 @@ def listen_toolkit(
"message": args_str,
},
)
logger.debug(f"[listen_toolkit] Sending activate data: {activate_data.model_dump()}")
await task_lock.put_queue(activate_data)
error = None
res = None
@ -134,7 +132,6 @@ def listen_toolkit(
"message": res_msg,
},
)
logger.debug(f"[listen_toolkit] Sending deactivate data: {deactivate_data.model_dump()}")
await task_lock.put_queue(deactivate_data)
if error is not None:
raise error
@ -181,12 +178,10 @@ def listen_toolkit(
"message": args_str,
},
)
logger.debug(f"[listen_toolkit sync] Sending activate data: {activate_data.model_dump()}")
_safe_put_queue(task_lock, activate_data)
error = None
res = None
try:
logger.debug(f"Executing toolkit method: {toolkit_name}.{method_name} for agent '{toolkit.agent_name}'")
res = func(*args, **kwargs)
# Safety check: if the result is a coroutine, we need to await it
if asyncio.iscoroutine(res):

View file

@ -2,11 +2,13 @@ import datetime
from camel.agents.chat_agent import AsyncStreamingChatAgentResponse
from camel.societies.workforce.single_agent_worker import SingleAgentWorker as BaseSingleAgentWorker
from camel.tasks.task import Task, TaskState, is_task_result_insufficient
from loguru import logger
from app.utils.agent import ListenChatAgent
from camel.societies.workforce.prompts import PROCESS_TASK_PROMPT
from colorama import Fore
from camel.societies.workforce.utils import TaskResult
from camel.utils.context_utils import ContextUtility
class SingleAgentWorker(BaseSingleAgentWorker):
@ -19,6 +21,8 @@ class SingleAgentWorker(BaseSingleAgentWorker):
pool_max_size: int = 10,
auto_scale_pool: bool = True,
use_structured_output_handler: bool = True,
context_utility: ContextUtility | None = None,
enable_workflow_memory: bool = False,
) -> None:
super().__init__(
description=description,
@ -28,6 +32,8 @@ class SingleAgentWorker(BaseSingleAgentWorker):
pool_max_size=pool_max_size,
auto_scale_pool=auto_scale_pool,
use_structured_output_handler=use_structured_output_handler,
context_utility=context_utility,
enable_workflow_memory=enable_workflow_memory,
)
self.worker = worker # change type hint
@ -130,8 +136,28 @@ class SingleAgentWorker(BaseSingleAgentWorker):
usage_info = response.info.get("usage") or response.info.get("token_usage")
total_tokens = usage_info.get("total_tokens", 0) if usage_info else 0
# collect conversation from working agent to
# accumulator for workflow memory
# Only transfer memory if workflow memory is enabled
if self.enable_workflow_memory:
accumulator = self._get_conversation_accumulator()
# transfer all memory records from working agent to accumulator
try:
# retrieve all context records from the working agent
work_records = worker_agent.memory.retrieve()
# write these records to the accumulator's memory
memory_records = [record.memory_record for record in work_records]
accumulator.memory.write_records(memory_records)
logger.debug(f"Transferred {len(memory_records)} memory records to accumulator")
except Exception as e:
logger.warning(f"Failed to transfer conversation to accumulator: {e}")
except Exception as e:
print(f"{Fore.RED}Error processing task {task.id}: {type(e).__name__}: {e}{Fore.RESET}")
logger.error(f"Error processing task {task.id}: {type(e).__name__}: {e}")
# Store error information in task result
task.result = f"{type(e).__name__}: {e!s}"
return TaskState.FAILED
@ -172,9 +198,12 @@ class SingleAgentWorker(BaseSingleAgentWorker):
print(f"======\n{Fore.GREEN}Response from {self}:{Fore.RESET}")
logger.info(f"Response from {self}:")
if not self.use_structured_output_handler:
# Handle native structured output parsing
if task_result is None:
logger.error("Error in worker step execution: Invalid task result")
print(f"{Fore.RED}Error in worker step execution: Invalid task result{Fore.RESET}")
task_result = TaskResult(
content="Failed to generate valid task result.",
@ -186,12 +215,17 @@ class SingleAgentWorker(BaseSingleAgentWorker):
f"\n{color}{task_result.content}{Fore.RESET}\n======", # type: ignore[union-attr]
)
if task_result.failed: # type: ignore[union-attr]
logger.error(f"{task_result.content}") # type: ignore[union-attr]
else:
logger.info(f"{task_result.content}") # type: ignore[union-attr]
task.result = task_result.content # type: ignore[union-attr]
if task_result.failed: # type: ignore[union-attr]
return TaskState.FAILED
if is_task_result_insufficient(task):
print(f"{Fore.RED}Task {task.id}: Content validation failed - task marked as failed{Fore.RESET}")
logger.warning(f"Task {task.id}: Content validation failed - task marked as failed")
return TaskState.FAILED
return TaskState.DONE

View file

@ -45,8 +45,13 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
future.set_result(response)
logger.debug(f"Processed response for message {message_id}")
else:
# Log unexpected messages
logger.warning(f"Received unexpected message: {response}")
message_summary = {
"id": response.get("id"),
"success": response.get("success"),
"has_result": "result" in response,
"result_type": type(response.get("result")).__name__ if "result" in response else None
}
logger.debug(f"Received unexpected message: {message_summary}")
except asyncio.CancelledError:
disconnect_reason = "Receive loop cancelled"

View file

@ -6,6 +6,25 @@ from app.component.environment import env
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
from camel.toolkits.mcp_toolkit import MCPToolkit
def _customize_function_parameters(schema: Dict[str, Any]) -> None:
r"""Customize function parameters for specific functions.
This method allows modifying parameter descriptions or other schema
attributes for specific functions.
"""
function_info = schema.get("function", {})
function_name = function_info.get("name", "")
parameters = function_info.get("parameters", {})
properties = parameters.get("properties", {})
required = parameters.get("required", [])
# Modify the notion-create-pages function to make parent optional
if function_name == "notion-create-pages":
required.remove("parent")
parameters["required"] = required
if "parent" in properties:
# Update the parent parameter description
properties["parent"]["description"] = "Optional. " + properties["parent"]["description"]
class NotionMCPToolkit(MCPToolkit, AbstractToolkit):
@ -33,68 +52,7 @@ class NotionMCPToolkit(MCPToolkit, AbstractToolkit):
}
}
}
super().__init__(config_dict=config_dict, timeout=timeout)
def get_tools(self) -> List[FunctionTool]:
r"""Returns a list of tools provided by the NotionMCPToolkit.
Returns:
List[FunctionTool]: List of available tools.
"""
all_tools = []
for client in self.clients:
try:
original_build_schema = client._build_tool_schema
def create_wrapper(orig_func):
def wrapper(mcp_tool):
return self._build_custom_tool_schema(
mcp_tool, orig_func
)
return wrapper
client._build_tool_schema = create_wrapper( # type: ignore[method-assign]
original_build_schema
)
client_tools = client.get_tools()
all_tools.extend(client_tools)
client._build_tool_schema = original_build_schema # type: ignore[method-assign]
except Exception as e:
logger.error(f"Failed to get tools from client: {e}")
return all_tools
def _build_custom_tool_schema(self, mcp_tool, original_build_schema):
r"""Build tool schema with custom modifications."""
schema = original_build_schema(mcp_tool)
self._customize_function_parameters(schema)
return schema
def _customize_function_parameters(self, schema: Dict[str, Any]) -> None:
r"""Customize function parameters for specific functions.
This method allows modifying parameter descriptions or other schema
attributes for specific functions.
"""
function_info = schema.get("function", {})
function_name = function_info.get("name", "")
parameters = function_info.get("parameters", {})
properties = parameters.get("properties", {})
# Modify the notion-create-pages function to make parent optional
if function_name == "notion-create-pages":
if "parent" in properties:
# Update the parent parameter description
properties["parent"]["description"] = (
"Optional. The parent under which the new pages will be created. "
"This can be a page (page_id), a database page (database_id), or "
"a data source/collection under a database (data_source_id). "
"If omitted, the new pages will be created as private pages at the workspace level. "
"Use data_source_id when you have a collection:// URL from the fetch tool."
)
super().__init__(config_dict=config_dict, timeout=timeout)
@classmethod
async def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
@ -104,6 +62,12 @@ class NotionMCPToolkit(MCPToolkit, AbstractToolkit):
await toolkit.connect()
# Use subclass implementation that inlines upstream processing
all_tools = toolkit.get_tools()
tool_schema = [
item.get_openai_tool_schema() for item in all_tools
]
#adjust tool schema
for item in tool_schema:
_customize_function_parameters(item)
for item in all_tools:
setattr(item, "_toolkit_name", cls.__name__)
tools.append(item)

View file

@ -51,19 +51,36 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
@listen_toolkit(
BaseSearchToolkit.search_google,
lambda _, query, search_type="web": f"with query '{query}' and {search_type} result pages",
lambda _, query, search_type="web", number_of_result_pages=10, start_page=1: f"with query '{query}', {search_type} type, {number_of_result_pages} result pages starting from page {start_page}",
)
def search_google(self, query: str, search_type: str = "web") -> list[dict[str, Any]]:
def search_google(
self,
query: str,
search_type: str = "web",
number_of_result_pages: int = 10,
start_page: int = 1
) -> list[dict[str, Any]]:
if env("GOOGLE_API_KEY") and env("SEARCH_ENGINE_ID"):
return super().search_google(query, search_type)
return super().search_google(query, search_type, number_of_result_pages, start_page)
else:
return self.cloud_search_google(query, search_type)
return self.cloud_search_google(query, search_type, number_of_result_pages, start_page)
def cloud_search_google(self, query: str, search_type):
def cloud_search_google(
self,
query: str,
search_type: str = "web",
number_of_result_pages: int = 10,
start_page: int = 1
):
url = env_not_empty("SERVER_URL")
res = httpx.get(
url + "/proxy/google",
params={"query": query, "search_type": search_type},
params={
"query": query,
"search_type": search_type,
"number_of_result_pages": number_of_result_pages,
"start_page": start_page
},
headers={"api-key": env_not_empty("cloud_api_key")},
)
return res.json()
@ -164,73 +181,73 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
# def search_bing(self, query: str) -> dict[str, Any]:
# return super().search_bing(query)
@listen_toolkit(BaseSearchToolkit.search_exa, lambda _, query, *args, **kwargs: f"{query}, {args}, {kwargs}")
def search_exa(
self,
query: str,
search_type: Literal["auto", "neural", "keyword"] = "auto",
category: None
| Literal[
"company",
"research paper",
"news",
"pdf",
"github",
"tweet",
"personal site",
"linkedin profile",
"financial report",
] = None,
include_text: List[str] | None = None,
exclude_text: List[str] | None = None,
use_autoprompt: bool = True,
text: bool = False,
) -> Dict[str, Any]:
if env("EXA_API_KEY"):
res = super().search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
return res
else:
return self.cloud_search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
def cloud_search_exa(
self,
query: str,
search_type: Literal["auto", "neural", "keyword"] = "auto",
category: None
| Literal[
"company",
"research paper",
"news",
"pdf",
"github",
"tweet",
"personal site",
"linkedin profile",
"financial report",
] = None,
include_text: List[str] | None = None,
exclude_text: List[str] | None = None,
use_autoprompt: bool = True,
text: bool = False,
):
url = env_not_empty("SERVER_URL")
logger.debug(f">>>>>>>>>>>>>>>>{url}<<<<")
res = httpx.post(
url + "/proxy/exa",
json={
"query": query,
"search_type": search_type,
"category": category,
"include_text": include_text,
"exclude_text": exclude_text,
"use_autoprompt": use_autoprompt,
"text": text,
},
headers={"api-key": env_not_empty("cloud_api_key")},
)
logger.debug(">>>>>>>>>>>>>>>>>")
logger.debug(res)
return res.json()
# @listen_toolkit(BaseSearchToolkit.search_exa, lambda _, query, *args, **kwargs: f"{query}, {args}, {kwargs}")
# def search_exa(
# self,
# query: str,
# search_type: Literal["auto", "neural", "keyword"] = "auto",
# category: None
# | Literal[
# "company",
# "research paper",
# "news",
# "pdf",
# "github",
# "tweet",
# "personal site",
# "linkedin profile",
# "financial report",
# ] = None,
# include_text: List[str] | None = None,
# exclude_text: List[str] | None = None,
# use_autoprompt: bool = True,
# text: bool = False,
# ) -> Dict[str, Any]:
# if env("EXA_API_KEY"):
# res = super().search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
# return res
# else:
# return self.cloud_search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
#
# def cloud_search_exa(
# self,
# query: str,
# search_type: Literal["auto", "neural", "keyword"] = "auto",
# category: None
# | Literal[
# "company",
# "research paper",
# "news",
# "pdf",
# "github",
# "tweet",
# "personal site",
# "linkedin profile",
# "financial report",
# ] = None,
# include_text: List[str] | None = None,
# exclude_text: List[str] | None = None,
# use_autoprompt: bool = True,
# text: bool = False,
# ):
# url = env_not_empty("SERVER_URL")
# logger.debug(f">>>>>>>>>>>>>>>>{url}<<<<")
# res = httpx.post(
# url + "/proxy/exa",
# json={
# "query": query,
# "search_type": search_type,
# "category": category,
# "include_text": include_text,
# "exclude_text": exclude_text,
# "use_autoprompt": use_autoprompt,
# "text": text,
# },
# headers={"api-key": env_not_empty("cloud_api_key")},
# )
# logger.debug(">>>>>>>>>>>>>>>>>")
# logger.debug(res)
# return res.json()
# @listen_toolkit(
# BaseSearchToolkit.search_alibaba_tongxiao,
@ -290,12 +307,12 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
# if env("BOCHA_API_KEY"):
# tools.append(FunctionTool(search_toolkit.search_bocha))
if env("EXA_API_KEY") or env("cloud_api_key"):
tools.append(FunctionTool(search_toolkit.search_exa))
# if env("EXA_API_KEY") or env("cloud_api_key"):
# tools.append(FunctionTool(search_toolkit.search_exa))
# if env("TONGXIAO_API_KEY"):
# tools.append(FunctionTool(search_toolkit.search_alibaba_tongxiao))
return tools
def get_tools(self) -> List[FunctionTool]:
return [FunctionTool(self.search_exa)]
# def get_tools(self) -> List[FunctionTool]:
# return [FunctionTool(self.search_exa)]

View file

@ -85,7 +85,6 @@ class Workforce(BaseWorkforce):
self.set_channel(TaskChannel())
self._state = WorkforceState.RUNNING
task.state = TaskState.OPEN
self._pending_tasks.append(task)
# Decompose the task into subtasks first
subtasks_result = self._decompose_task(task)
@ -133,7 +132,9 @@ class Workforce(BaseWorkforce):
# Find task content
task_obj = get_camel_task(item.task_id, tasks)
if task_obj is None:
logger.warning(f"[WF] WARN: Task {item.task_id} not found in tasks list during ASSIGN phase. This may indicate a task tree inconsistency.")
logger.warning(
f"[WF] WARN: Task {item.task_id} not found in tasks list during ASSIGN phase. This may indicate a task tree inconsistency."
)
content = ""
else:
content = task_obj.content
@ -179,7 +180,11 @@ class Workforce(BaseWorkforce):
await super()._post_task(task, assignee_id)
def add_single_agent_worker(
self, description: str, worker: ListenChatAgent, pool_max_size: int = DEFAULT_WORKER_POOL_SIZE
self,
description: str,
worker: ListenChatAgent,
pool_max_size: int = DEFAULT_WORKER_POOL_SIZE,
enable_workflow_memory: bool = False,
) -> BaseWorkforce:
if self._state == WorkforceState.RUNNING:
raise RuntimeError("Cannot add workers while workforce is running. Pause the workforce first.")
@ -195,6 +200,8 @@ class Workforce(BaseWorkforce):
worker=worker,
pool_max_size=pool_max_size,
use_structured_output_handler=self.use_structured_output_handler,
context_utility=None, # Will be set during save/load operations
enable_workflow_memory=enable_workflow_memory,
)
self._children.append(worker_node)

View file

@ -5,7 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = "==3.10.16"
dependencies = [
"camel-ai[eigent]==0.2.76a13",
"camel-ai[eigent]==0.2.78",
"fastapi>=0.115.12",
"fastapi-babel>=1.0.0",
"uvicorn[standard]>=0.34.2",

18
backend/uv.lock generated
View file

@ -122,6 +122,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" },
]
[[package]]
name = "astor"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/21/75b771132fee241dfe601d39ade629548a9626d1d39f333fde31bc46febe/astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e", size = 35090, upload-time = "2019-12-10T01:50:35.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/88/97eef84f48fa04fbd6750e62dcceafba6c63c81b7ac1420856c8dcc0a3f9/astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5", size = 27488, upload-time = "2019-12-10T01:50:33.628Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
@ -240,7 +249,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "camel-ai", extras = ["eigent"], specifier = "==0.2.76a13" },
{ name = "camel-ai", extras = ["eigent"], specifier = "==0.2.78" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "fastapi-babel", specifier = ">=1.0.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
@ -324,9 +333,10 @@ wheels = [
[[package]]
name = "camel-ai"
version = "0.2.76a13"
version = "0.2.78"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astor" },
{ name = "colorama" },
{ name = "docstring-parser" },
{ name = "httpx" },
@ -339,9 +349,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/7c/0145edf0307e360557917de28691eb0c41b36b017a28c6b67e58a729a6da/camel_ai-0.2.76a13.tar.gz", hash = "sha256:487570c36a39a333ae8000783babd5a82350a829aaa8aa2ae712470b596cafe1", size = 950278, upload-time = "2025-10-06T06:09:46.064Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/2b/cd5181bfd0ebcf567a088ee5c1e3768b132ba4b1489ee19d5fb0bd679586/camel_ai-0.2.78.tar.gz", hash = "sha256:24745da225da7da96dcd85f72d143c6104569c17f14280c369d7e82b86851284", size = 964632, upload-time = "2025-10-15T17:20:54.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/46/9886106669491737631178830bce79bd7bf63391db4d2200f645089dd9df/camel_ai-0.2.76a13-py3-none-any.whl", hash = "sha256:b860412e4a5b5fc31b0cc3d4b1eeefcd02382d9a5aced252856a1eff0285a97b", size = 1400549, upload-time = "2025-10-06T06:09:43.291Z" },
{ url = "https://files.pythonhosted.org/packages/01/81/0cfb1c0d9da589665e2eb4471887967e70bba428638c37fb4f6a78baf300/camel_ai-0.2.78-py3-none-any.whl", hash = "sha256:356624da13dfe0c55ef43dc509c18ce029f67fe3997966495a4ce9be931078d5", size = 1415578, upload-time = "2025-10-15T17:20:51.727Z" },
]
[package.optional-dependencies]

View file

@ -613,7 +613,7 @@ function registerIpcHandlers() {
const stats = await fsp.stat(filePath);
if (stats.isDirectory()) {
log.error('Path is a directory, not a file:', filePath);
return { success: false, error: 'EISDIR: illegal operation on a directory, read' };
return { success: false, error: 'Path is a directory, not a file' };
}
// Read file content

View file

@ -64,6 +64,9 @@ export class WebViewManager {
}
const view = new WebContentsView({
webPreferences: {
// Use a separate session partition for webviews to isolate storage from main window
// This ensures clearing webview storage won't affect main window's auth data
partition: 'persist:agent-webview',
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: true,
@ -269,6 +272,7 @@ export class WebViewManager {
if (!webViewInfo.view.webContents.isDestroyed()) {
webViewInfo.view.webContents.removeAllListeners()
// Now safe to clear all storage since webviews use separate partition
webViewInfo.view.webContents.session.clearCache()
webViewInfo.view.webContents.session.clearStorageData({
storages: ['cookies', 'localstorage', 'websql', 'indexdb', 'serviceworkers', 'cachestorage']

View file

@ -0,0 +1,36 @@
"""modify_chat_history_add_project_id
Revision ID: eec7242b3a9b
Revises: d74ab2a44600
Create Date: 2025-10-15 14:46:47.904254
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision: str = "eec7242b3a9b"
down_revision: Union[str, None] = "d74ab2a44600"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("chat_history", sa.Column("project_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.create_index(op.f("ix_chat_history_project_id"), "chat_history", ["project_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_chat_history_project_id"), table_name="chat_history")
op.drop_column("chat_history", "project_id")
# ### end Alembic commands ###

View file

@ -4,7 +4,7 @@ from typing import Optional
from enum import IntEnum
from sqlalchemy_utils import ChoiceType
from app.model.abstract.model import AbstractModel, DefaultTimes
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
class ChatStatus(IntEnum):
@ -16,6 +16,7 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True):
id: int = Field(default=None, primary_key=True)
user_id: int = Field(index=True)
task_id: str = Field(index=True, unique=True)
project_id: str = Field(index=True, unique=False, nullable=True)
question: str
language: str
model_platform: str
@ -34,6 +35,7 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True):
class ChatHistoryIn(BaseModel):
task_id: str
project_id: str | None = None
user_id: int | None = None
question: str
language: str
@ -54,6 +56,7 @@ class ChatHistoryIn(BaseModel):
class ChatHistoryOut(BaseModel):
id: int
task_id: str
project_id: str | None = None
question: str
language: str
model_platform: str
@ -68,9 +71,17 @@ class ChatHistoryOut(BaseModel):
tokens: int
status: int
@model_validator(mode="after")
def fill_project_id_from_task_id(self):
"""fill by task_id when project_id is None"""
if self.project_id is None:
self.project_id = self.task_id
return self
class ChatHistoryUpdate(BaseModel):
project_name: str | None = None
summary: str | None = None
tokens: int | None = None
status: int | None = None
project_id: str | None = None

View file

@ -294,10 +294,10 @@ export default function ChatBox(): JSX.Element {
onTyping={scrollToBottom}
/>
<div className="flex gap-2 flex-wrap">
{item.fileList?.map((file) => {
{item.fileList?.map((file, fileIndex) => {
return (
<div
key={"file-" + file.name}
key={`file-${item.id}-${fileIndex}-${file.name}`}
onClick={() => {
// set selected file
chatStore.setSelectedFile(
@ -376,10 +376,10 @@ export default function ChatBox(): JSX.Element {
onTyping={scrollToBottom}
/> */}
<div className="flex gap-2 flex-wrap">
{item.fileList?.map((file) => {
{item.fileList?.map((file, fileIndex) => {
return (
<div
key={"file-" + file.name}
key={`file-${item.id}-${fileIndex}-${file.name}`}
onClick={() => {
// set selected file
chatStore.setSelectedFile(

View file

@ -31,12 +31,14 @@ const nodeTypes: NodeTypes = {
node: (props: any) => <CustomNodeComponent {...props} />,
};
const VIEWPORT_ANIMATION_DURATION = 500;
export default function Workflow({
taskAssigning,
}: {
taskAssigning: Agent[];
}) {
const {t} = useTranslation();
const { t } = useTranslation();
const chatStore = useChatStore();
const [isEditMode, setIsEditMode] = useState(false);
const [lastViewport, setLastViewport] = useState({ x: 0, y: 0, zoom: 1 });
@ -245,7 +247,7 @@ export default function Workflow({
},
position: isEditMode
? node.position
: { x: index * (342+20) + 8, y: 16 },
: { x: index * (342 + 20) + 8, y: 16 },
};
} else {
return {
@ -259,7 +261,7 @@ export default function Workflow({
isEditMode: isEditMode,
workerInfo: agent?.workerInfo,
},
position: { x: index * (342+20) + 8, y: 16 },
position: { x: index * (342 + 20) + 8, y: 16 },
type: "node",
};
}
@ -293,6 +295,24 @@ export default function Workflow({
};
}, [getViewport, setViewport, isEditMode]);
const [isAnimating, setIsAnimating] = useState(false);
const moveViewport = (dx: number) => {
if (isAnimating) return;
const viewport = getViewport();
setIsAnimating(true);
// Prevent scrolling past x=0 (too far right) when moving left
const newX = dx > 0 ? Math.min(0, viewport.x + dx) : viewport.x + dx;
setViewport(
{ x: newX, y: viewport.y, zoom: viewport.zoom },
{
duration: VIEWPORT_ANIMATION_DURATION,
}
);
setTimeout(() => {
setIsAnimating(false);
}, VIEWPORT_ANIMATION_DURATION);
};
const handleShare = async (taskId: string) => {
share(taskId);
};
@ -343,12 +363,7 @@ export default function Workflow({
variant="ghost"
size="icon"
onClick={() => {
const viewport = getViewport();
const newX = Math.min(0, viewport.x + 200);
setViewport(
{ x: newX, y: viewport.y, zoom: viewport.zoom },
{ duration: 500 }
);
moveViewport(200);
}}
>
<ChevronLeft className="w-4 h-4 text-icon-primary" />
@ -356,14 +371,7 @@ export default function Workflow({
<Button
variant="ghost"
size="icon"
onClick={() => {
const viewport = getViewport();
const newX = viewport.x - 200;
setViewport(
{ x: newX, y: viewport.y, zoom: viewport.zoom },
{ duration: 500 }
);
}}
onClick={() => moveViewport(-200)}
>
<ChevronRight className="w-4 h-4 text-icon-primary" />
</Button>

View file

@ -622,6 +622,8 @@ export default function SettingModels() {
? t("setting.gpt-4.1")
: cloud_model_type === "claude-sonnet-4-5"
? t("setting.claude-sonnet-4-5")
: cloud_model_type === "claude-sonnet-4-20250514"
? t("setting.claude-sonnet-4")
: cloud_model_type === "claude-3-5-haiku-20241022"
? t("setting.claude-3.5-haiku")
: cloud_model_type === "gpt-5"
@ -658,6 +660,9 @@ export default function SettingModels() {
<SelectItem value="claude-sonnet-4-5">
Claude Sonnet 4-5
</SelectItem>
<SelectItem value="claude-sonnet-4-20250514">
Claude Sonnet 4
</SelectItem>
<SelectItem value="claude-3-5-haiku-20241022">
Claude 3.5 Haiku
</SelectItem>

View file

@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware';
// type definition
type InitState = 'permissions' | 'carousel' | 'done';
type ModelType = 'cloud' | 'local' | 'custom';
type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano';
type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano';
// auth info interface
interface AuthInfo {

View file

@ -235,7 +235,9 @@ const chatStore = create<ChatStore>()(
apiModel = {
api_key: res.value,
model_type: cloud_model_type,
model_platform: cloud_model_type.includes('gpt') ? 'openai' : 'gemini',
model_platform: cloud_model_type.includes('gpt') ? 'openai' :
cloud_model_type.includes('claude') ? 'anthropic' :
cloud_model_type.includes('gemini') ? 'gemini' : 'openai-compatible-model',
api_url: res.api_url,
extra_params: {}
}
@ -885,12 +887,9 @@ const chatStore = create<ChatStore>()(
taskId as string
);
if (!type && import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && res.length > 0) {
// Filter out directories, only upload actual files
const files = res.filter((file: any) => !file.isFolder);
// Upload files sequentially to avoid overwhelming the server
const uploadResults = await Promise.allSettled(
files.map(async (file: any) => {
res.filter((file: any) => !file.isFolder).map(async (file: any) => {
try {
// Read file content using Electron API
const result = await window.ipcRenderer.invoke('read-file', file.path);

View file

@ -67,22 +67,25 @@ export default defineConfig(({ command, mode }) => {
renderer: {},
}),
],
server: process.env.VSCODE_DEBUG && (() => {
const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL)
return {
host: url.hostname,
port: +url.port,
proxy: {
'/api': {
target: env.VITE_PROXY_URL,
changeOrigin: true,
// rewrite: path => path.replace(/^\/api/, ''),
server: {
open: false,
...(process.env.VSCODE_DEBUG && (() => {
const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL)
return {
host: url.hostname,
port: +url.port,
proxy: {
'/api': {
target: env.VITE_PROXY_URL,
changeOrigin: true,
// rewrite: path => path.replace(/^\/api/, ''),
},
},
},
}
})(),
clearScreen: false,
}
})()),
clearScreen: false,
}
}
})