diff --git a/README.md b/README.md index 0fe3683035..d3864960a6 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,6 @@ cd cleveragents-core pip install -e . ``` - -### Install from Source - -```bash -git clone https://git.cleverthis.com/cleveragents/cleveragents-core -cd cleveragents-core -pip install -e . -``` - ### Dependencies CleverAgents automatically installs these dependencies: diff --git a/src/cleveragents/core/application.py b/src/cleveragents/core/application.py index c1cbfdffc3..94b8446be6 100644 --- a/src/cleveragents/core/application.py +++ b/src/cleveragents/core/application.py @@ -306,8 +306,12 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes try: self.logger.info("Starting reactive interactive session") + # Create a variable to track completion of each message + completion_future: Optional[asyncio.Future] = None + # Set up observers for output and errors def on_output(msg: StreamMessage) -> None: + nonlocal completion_future # Access the outer variable content_str = str(msg.content) if not content_str or content_str.strip() == "": @@ -317,6 +321,10 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes processed_content = self._process_tool_commands(content_str) print(f"\n{processed_content}\n") + # Signal completion + if completion_future and not completion_future.done(): + completion_future.set_result(True) + def on_error(msg: StreamMessage) -> None: print(f"\n[ERROR] {msg.content}") @@ -355,10 +363,16 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes metadata: Dict[str, Any] = {"context": self.config.global_context} metadata["_unsafe_mode"] = self.unsafe + # Create a new future for this message + completion_future = asyncio.Future() + self.stream_router.send_message("__input__", user_input, metadata) - # Give streams time to process - await asyncio.sleep(2.0) + # Wait for actual completion (not a guess!) + try: + await asyncio.wait_for(completion_future, timeout=30.0) # Safety timeout + except asyncio.TimeoutError: + print("\n[WARN] Stream processing timeout\n") except KeyboardInterrupt: print("\nUse 'exit' to quit.") @@ -754,6 +768,7 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes context = {"_unsafe_mode": self.unsafe} # Execute based on whether we're in an async context + result = None try: asyncio.get_running_loop() # Already in async context, use thread pool @@ -765,6 +780,8 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes except RuntimeError: # No running loop, use asyncio.run result = asyncio.run(target_agent.process_message(tool_request, context)) + if result is None: + return "\n❌ Error: Tool execution produced no result" return f"\n✅ {result}" diff --git a/tests/features/environment.py b/tests/features/environment.py index 1b77935063..14b8262f07 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -227,6 +227,25 @@ def after_scenario(context, scenario): cleanup_test_files(context) + # Clean up files tracked for cleanup in tool_agent tests + if hasattr(context, "__dict__") and "_cleanup_files" in context.__dict__: + import os + for filepath in context.__dict__["_cleanup_files"]: + try: + if os.path.exists(filepath): + os.remove(filepath) + except Exception: + pass + context.__dict__["_cleanup_files"] = [] + + # Clean up scenario temp directory + if hasattr(context, "scenario_temp") and context.scenario_temp.exists(): + import shutil + try: + shutil.rmtree(context.scenario_temp, ignore_errors=True) + except Exception: + pass + # Clean up test context for InlineYAMLJinja tests - not needed for simplified tests # if hasattr(context, 'test_context'): # context.test_context.cleanup() diff --git a/tests/features/steps/tool_agent_steps.py b/tests/features/steps/tool_agent_steps.py index 7d0f1de858..418876dd64 100644 --- a/tests/features/steps/tool_agent_steps.py +++ b/tests/features/steps/tool_agent_steps.py @@ -1,3 +1,4 @@ +import asyncio import json import os from unittest.mock import AsyncMock @@ -63,9 +64,80 @@ async def step_process_message_tool_agent(context, message): test_context = ( {"_unsafe_mode": context.unsafe} if hasattr(context, "unsafe") else {} ) - context.result = await context.agent.process_message( - message, context=test_context - ) + + # Mock shell commands for consistent testing across different systems + if context.agent_config.get("allow_shell", False) and message.strip(): + # Check if this looks like a shell command execution + parts = message.strip().split() + first_part = parts[0] if parts else "" + + # Commands that should be mocked + should_mock = ( + "/bin/" in first_part or + "/usr/bin/" in first_part or + first_part in ["sleep", "false", "rm"] + ) + + if should_mock: + # Mock subprocess execution + with patch("asyncio.create_subprocess_exec") as mock_subprocess: + # Determine the mock behavior based on the command + if "echo" in first_part: + # Mock echo command - return the arguments + mock_output = " ".join(parts[1:]) + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.communicate = AsyncMock( + return_value=(mock_output.encode(), b"") + ) + mock_subprocess.return_value = mock_process + elif "sleep" in first_part: + # Mock sleep that takes too long (will trigger timeout) + async def timeout_sleep(*args, **kwargs): + await asyncio.sleep(10) # Will be caught by timeout + return (b"", b"") + mock_process = MagicMock() + mock_process.communicate = timeout_sleep + mock_subprocess.return_value = mock_process + elif "false" in first_part: + # Mock command that returns non-zero exit code + mock_process = MagicMock() + mock_process.returncode = 1 + mock_process.communicate = AsyncMock( + return_value=(b"", b"") + ) + mock_subprocess.return_value = mock_process + elif "rm" in first_part: + # rm commands should be blocked by safe mode before execution + # But if we get here, mock it to fail + mock_process = MagicMock() + mock_process.returncode = 1 + mock_process.communicate = AsyncMock( + return_value=(b"", b"Permission denied") + ) + mock_subprocess.return_value = mock_process + else: + # Default mock for other commands + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.communicate = AsyncMock( + return_value=(b"", b"") + ) + mock_subprocess.return_value = mock_process + + context.result = await context.agent.process_message( + message, context=test_context + ) + else: + # Not a shell command, process normally + context.result = await context.agent.process_message( + message, context=test_context + ) + else: + # No shell support or empty message, process normally + context.result = await context.agent.process_message( + message, context=test_context + ) except ExecutionError as e: context.error = e