Merge branch 'main' into chore/search_tool

This commit is contained in:
Puzhen Zhang 2025-11-03 18:56:49 +01:00 committed by GitHub
commit 4264b17fb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 242 additions and 44 deletions

View file

@ -57,7 +57,7 @@ async def post(data: Chat, request: Request):
chat_logger.info(f"Set search config: {key}", extra={"project_id": data.project_id})
email_sanitized = re.sub(r'[\\/*?:"<>|\s]', "_", data.email.split("@")[0]).strip(".")
camel_log = Path.home() / ".eigent" / email_sanitized / ("task_" + data.project_id) / "camel_logs"
camel_log = Path.home() / ".eigent" / email_sanitized / ("project_" + data.project_id) / ("task_" + data.task_id) / "camel_logs"
camel_log.mkdir(parents=True, exist_ok=True)
os.environ["CAMEL_LOG_DIR"] = str(camel_log)

View file

@ -1,5 +1,9 @@
import asyncio
import logging
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
from camel.toolkits.terminal_toolkit import TerminalToolkit as BaseTerminalToolkit
from camel.toolkits.terminal_toolkit.terminal_toolkit import _to_plain
from app.component.environment import env
@ -12,6 +16,8 @@ from app.service.task import process_task
@auto_listen_toolkit(BaseTerminalToolkit)
class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
agent_name: str = Agents.developer_agent
_thread_pool: Optional[ThreadPoolExecutor] = None
_thread_local = threading.local()
def __init__(
self,
@ -31,6 +37,11 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
self.agent_name = agent_name
if working_directory is None:
working_directory = env("file_save_path", os.path.expanduser("~/.eigent/terminal/"))
if TerminalToolkit._thread_pool is None:
TerminalToolkit._thread_pool = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="terminal_toolkit"
)
super().__init__(
timeout=timeout,
working_directory=working_directory,
@ -55,16 +66,57 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
def _update_terminal_output(self, output: str):
task_lock = get_task_lock(self.api_task_id)
# This method will be called during init. At that time, the process_task_id parameter does not exist, so it is set to be empty default
process_task_id = process_task.get("")
task = asyncio.create_task(
task_lock.put_queue(
ActionTerminalData(
action=Action.terminal,
process_task_id=process_task_id,
data=output,
)
# Create the coroutine
coro = task_lock.put_queue(
ActionTerminalData(
action=Action.terminal,
process_task_id=process_task_id,
data=output,
)
)
if hasattr(task_lock, "add_background_task"):
task_lock.add_background_task(task)
# Try to get the current event loop, if none exists, create a new one in a thread
try:
loop = asyncio.get_running_loop()
# If we're in an async context, schedule the coroutine
task = loop.create_task(coro)
if hasattr(task_lock, "add_background_task"):
task_lock.add_background_task(task)
except RuntimeError:
self._thread_pool.submit(self._run_coro_in_thread, coro,task_lock)
@staticmethod
def _run_coro_in_thread(coro,task_lock):
"""
Execute coro in the thread pool, with each thread bound to a long-term event loop
"""
if not hasattr(TerminalToolkit._thread_local, "loop"):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
TerminalToolkit._thread_local.loop = loop
else:
loop = TerminalToolkit._thread_local.loop
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
TerminalToolkit._thread_local.loop = loop
try:
task = loop.create_task(coro)
if hasattr(task_lock, "add_background_task"):
task_lock.add_background_task(task)
loop.run_until_complete(task)
except Exception as e:
logging.error(
f"Failed to execute coroutine in thread pool: {str(e)}",
exc_info=True
)
@classmethod
def shutdown(cls):
if cls._thread_pool:
cls._thread_pool.shutdown(wait=True)
cls._thread_pool = None

View file

@ -0,0 +1,100 @@
import asyncio
import threading
import time
import pytest
from app.service.task import task_locks, TaskLock
from app.utils.toolkit.terminal_toolkit import TerminalToolkit
@pytest.mark.unit
class TestTerminalToolkit:
"""Test to verify the RuntimeError: no running event loop."""
def test_no_runtime_error_in_sync_context(self):
"""Test no running event loop."""
test_api_task_id = "test_api_task_123"
if test_api_task_id not in task_locks:
task_locks[test_api_task_id] = TaskLock(id=test_api_task_id, queue=asyncio.Queue(), human_input={})
toolkit = TerminalToolkit("test_api_task_123")
# This should NOT raise RuntimeError: no running event loop
# This simulates the exact scenario from the error traceback
try:
toolkit._write_to_log("/tmp/test.log", "Test output")
time.sleep(0.1) # Give thread time to complete
except RuntimeError as e:
if "no running event loop" in str(e):
pytest.fail("RuntimeError: no running event loop should not be raised - the fix is not working!")
else:
raise # Re-raise if it's a different RuntimeError
def test_multiple_calls_no_runtime_error(self):
"""Test that multiple calls don't raise RuntimeError."""
test_api_task_id = "test_api_task_123"
if test_api_task_id not in task_locks:
task_locks[test_api_task_id] = TaskLock(id=test_api_task_id, queue=asyncio.Queue(), human_input={})
toolkit = TerminalToolkit("test_api_task_123")
# Make multiple calls - none should raise RuntimeError
try:
for i in range(5):
toolkit._write_to_log(f"/tmp/test_{i}.log", f"Output {i}")
time.sleep(0.2) # Give threads time to complete
except RuntimeError as e:
if "no running event loop" in str(e):
pytest.fail("RuntimeError: no running event loop should not be raised!")
else:
raise
def test_thread_safety_no_runtime_error(self):
"""Test thread safety without RuntimeError."""
test_api_task_id = "test_api_task_123"
if test_api_task_id not in task_locks:
task_locks[test_api_task_id] = TaskLock(id=test_api_task_id, queue=asyncio.Queue(), human_input={})
toolkit = TerminalToolkit("test_api_task_123")
# Create multiple threads that call _write_to_log
threads = []
for i in range(5):
thread = threading.Thread(
target=toolkit._write_to_log,
args=(f"/tmp/test_{i}.log", f"Thread {i} output")
)
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
time.sleep(0.2) # Give async operations time to complete
# Should not have raised any RuntimeError
def test_async_context_still_works(self):
"""Test that async context still works without RuntimeError."""
test_api_task_id = "test_api_task_123"
if test_api_task_id not in task_locks:
task_locks[test_api_task_id] = TaskLock(id=test_api_task_id, queue=asyncio.Queue(), human_input={})
toolkit = TerminalToolkit("test_api_task_123")
async def test_async_context():
toolkit._write_to_log("/tmp/async_test.log", "Async context test")
await asyncio.sleep(0.1)
# Should work in async context without RuntimeError
try:
asyncio.run(test_async_context())
except RuntimeError as e:
if "no running event loop" in str(e):
pytest.fail("RuntimeError: no running event loop should not be raised in async context!")
else:
raise

View file

@ -1 +0,0 @@
# Utils package

View file

@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency files first
COPY pyproject.toml uv.lock ./
COPY server/pyproject.toml server/uv.lock ./
# Install the project's dependencies
RUN --mount=type=cache,target=/root/.cache/uv \
@ -29,7 +29,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
COPY . /app
COPY server/ /app
# Copy the utils directory from the parent project
COPY utils /app/utils
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev
@ -45,7 +49,7 @@ RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/
ENV PATH="/app/.venv/bin:$PATH"
# Copy and make the start script executable
COPY start.sh /app/start.sh
COPY server/start.sh /app/start.sh
RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh
# Reset the entrypoint, don't invoke `uv`

View file

@ -1,4 +1,11 @@
from logging.config import fileConfig
import sys
import pathlib
# Add project root to Python path to import shared utils
_project_root = pathlib.Path(__file__).parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from sqlalchemy import engine_from_config, pool
from alembic import context

View file

@ -25,8 +25,8 @@ services:
# FastAPI Application
api:
build:
context: .
dockerfile: Dockerfile
context: ..
dockerfile: server/Dockerfile
args:
database_url: postgresql://postgres:123456@postgres:5432/eigent
container_name: eigent_api

View file

@ -152,10 +152,18 @@ function groupMessagesByQuery(messages: any[]) {
otherMessages: []
};
}
} else {
// Other messages (assistant responses, etc.)
} else {
// Other messages (assistant responses, errors, etc.)
if (currentGroup) {
currentGroup.otherMessages.push(message);
} else {
// If there is no current user group yet (e.g., the first message is from agent/error),
// create an anonymous group to ensure the message is rendered.
currentGroup = {
queryId: `orphan-${message.id}`,
userMessage: null,
otherMessages: [message]
};
}
}
});

View file

@ -38,7 +38,25 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
// Show task if this query group has a task message OR if it's the most recent user query during splitting
// During splitting phase (no to_sub_tasks yet), show task for the most recent query only
// Exclude human-reply scenarios (when user is replying to an activeAsk)
const isHumanReply = queryGroup.userMessage &&
activeTaskId &&
chatState.tasks[activeTaskId] &&
(chatState.tasks[activeTaskId].activeAsk ||
// Check if this user message follows an 'ask' message in the message sequence
(() => {
const messages = chatState.tasks[activeTaskId].messages;
const userMessageIndex = messages.findIndex((m: any) => m.id === queryGroup.userMessage.id);
if (userMessageIndex > 0) {
// Check the previous message - if it's an agent message with step 'ask', this is a human-reply
const prevMessage = messages[userMessageIndex - 1];
return prevMessage?.role === 'agent' && prevMessage?.step === 'ask';
}
return false;
})());
const isLastUserQuery = !queryGroup.taskMessage &&
!isHumanReply &&
activeTaskId &&
chatState.tasks[activeTaskId] &&
queryGroup.userMessage &&
@ -131,21 +149,23 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
}}
className="relative"
>
{/* User Query */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="px-2 py-sm"
>
<MessageCard
id={queryGroup.userMessage.id}
role={queryGroup.userMessage.role}
content={queryGroup.userMessage.content}
onTyping={() => {}}
attaches={queryGroup.userMessage.attaches}
/>
</motion.div>
{/* User Query (render only if exists) */}
{queryGroup.userMessage && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="px-2 py-sm"
>
<MessageCard
id={queryGroup.userMessage.id}
role={queryGroup.userMessage.role}
content={queryGroup.userMessage.content}
onTyping={() => {}}
attaches={queryGroup.userMessage.attaches}
/>
</motion.div>
)}
{/* Sticky Task Box - Show for each query group that has a task */}
{task && (

View file

@ -178,7 +178,7 @@ export default function HistorySidebar() {
try {
//TODO(file): rename endpoint to use project_id
//TODO(history): make sure to sync to projectId when updating endpoint
await (window as any).ipcRenderer.invoke('delete-task-files', email, history.task_id);
await (window as any).ipcRenderer.invoke('delete-task-files', email, history.task_id, history.project_id ?? undefined);
} catch (error) {
console.warn("Local file cleanup failed:", error);
}

View file

@ -4,8 +4,6 @@ import { Plus } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useUser } from "@stackframe/react";
import { hasStackKeys } from "@/lib";
import { useAuthStore } from "@/store/authStore";
import { MenuToggleGroup, MenuToggleItem } from "@/components/MenuButton/MenuButton";
import Project from "@/pages/Dashboard/Project";
@ -37,10 +35,8 @@ export default function Home() {
const [activeTab, setActiveTab] = useState<"projects" | "workers" | "trigger" | "settings" | "mcp_tools">(tabParam || "projects");
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const HAS_STACK_KEYS = hasStackKeys();
const stackUser = HAS_STACK_KEYS ? useUser({ or: 'anonymous-if-exists' }) : null;
const { username, email } = useAuthStore();
const displayName = stackUser?.displayName ?? stackUser?.primaryEmail ?? username ?? email ?? "";
const displayName = username ?? email ?? "";
// Sync activeTab with URL changes
useEffect(() => {

View file

@ -1 +1,3 @@
from . import traceroot_wrapper
__all__ = ['traceroot_wrapper']

View file

@ -1,9 +1,16 @@
from pathlib import Path
from typing import Callable
import logging
import traceroot
from dotenv import load_dotenv
# Try to import traceroot, but handle gracefully if not available
try:
import traceroot
TRACEROOT_AVAILABLE = True
except ImportError:
TRACEROOT_AVAILABLE = False
traceroot = None
# Auto-detect module name based on caller's path
def _get_module_name():
"""Automatically detect if this is being called from backend or server."""
@ -26,7 +33,7 @@ env_path = Path(__file__).resolve().parents[1] / '.env'
load_dotenv(env_path)
if traceroot.init():
if TRACEROOT_AVAILABLE and traceroot.init():
from traceroot.logger import get_logger as _get_traceroot_logger
trace = traceroot.trace
@ -42,7 +49,7 @@ if traceroot.init():
# Log successful initialization
module_name = _get_module_name()
_init_logger = _get_traceroot_logger("traceroot_wrapper")
_init_logger.info("TraceRoot initialized successfully", extra={"backend": "traceroot", "module": module_name})
_init_logger.info("TraceRoot initialized successfully", extra={"backend": "traceroot", "service_module": module_name})
else:
# No-op implementations when TraceRoot is not configured
def trace(*args, **kwargs):
@ -69,7 +76,10 @@ else:
# Log fallback mode
_fallback_logger = logging.getLogger("traceroot_wrapper")
_fallback_logger.warning("TraceRoot not initialized - using Python logging as fallback")
if TRACEROOT_AVAILABLE:
_fallback_logger.warning("TraceRoot available but not initialized - using Python logging as fallback")
else:
_fallback_logger.warning("TraceRoot not available - using Python logging as fallback")
__all__ = ['trace', 'get_logger', 'is_enabled']