mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-19 07:59:39 +00:00
Merge branch 'main' into chore/search_tool
This commit is contained in:
commit
4264b17fb4
16 changed files with 242 additions and 44 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
100
backend/tests/unit/utils/test_terminal_toolkit.py
Normal file
100
backend/tests/unit/utils/test_terminal_toolkit.py
Normal 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
|
||||
|
||||
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Utils package
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
from . import traceroot_wrapper
|
||||
|
||||
__all__ = ['traceroot_wrapper']
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue