diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 90d13f8e..6a262c6e 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -53,6 +53,7 @@ from backend.apps.agents.manager.session.SessionLifecycleMixin import SessionLif from backend.apps.agents.manager.MessagingMixin import MessagingMixin from backend.apps.agents.manager.SessionControlMixin import SessionControlMixin from backend.apps.agents.manager.AgentLaunchMixin import AgentLaunchMixin +from backend.apps.agents.manager.MockAgentMixin import MockAgentMixin from backend.apps.agents.manager.RunSupportMixin import RunSupportMixin from backend.apps.agents.manager.permissions import gate_hooks from backend.apps.agents.manager.session.workspace_git import ensure_cwd_git_repo @@ -71,7 +72,7 @@ logger = logging.getLogger(__name__) os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") -class AgentManager(SessionLifecycleMixin, MessagingMixin, SessionControlMixin, AgentLaunchMixin, RunSupportMixin): +class AgentManager(SessionLifecycleMixin, MessagingMixin, SessionControlMixin, AgentLaunchMixin, MockAgentMixin, RunSupportMixin): @typechecked def __init__(self): self.sessions: Dict[str, AgentSession] = {} diff --git a/backend/apps/agents/manager/AgentLaunchMixin.py b/backend/apps/agents/manager/AgentLaunchMixin.py index f69d0492..87ed2c29 100644 --- a/backend/apps/agents/manager/AgentLaunchMixin.py +++ b/backend/apps/agents/manager/AgentLaunchMixin.py @@ -1,9 +1,8 @@ -"""Agent run entry points for AgentManager: launch a new top-level run, run the no-SDK mock -fallback, and the staticmethod invoke_agent helper. Split into a mixin to keep the manager file -under the size ceiling; self.p_run_agent_loop / self.p_stream_text / self.sessions resolve across -the MRO exactly as before.""" +"""Agent run entry points for AgentManager: launch a new top-level run and the staticmethod +invoke_agent helper (fork-and-send a sub-agent). The no-SDK mock fallback lives in MockAgentMixin. +Split into a mixin to keep the manager file under the size ceiling; self.p_run_agent_loop / +self.sessions resolve across the MRO exactly as before.""" -import asyncio import logging import os from datetime import datetime @@ -14,7 +13,7 @@ from typing import Dict, List, Optional from typeguard import typechecked from backend.apps.agents.core.models import ( - AgentConfig, AgentSession, ApprovalRequest, Message, MessageBranch, + AgentConfig, AgentSession, Message, MessageBranch, ) from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.settings.settings import load_settings @@ -134,101 +133,6 @@ class AgentLaunchMixin: return session - @typechecked - async def p_run_mock_agent(self, session_id: str, prompt: str): - """Mock agent loop for development without claude_agent_sdk installed.""" - session = self.sessions.get(session_id) - if not session: - return - - await asyncio.sleep(1) - - request_id = uuid4().hex - approval_req = ApprovalRequest( - id=request_id, - session_id=session_id, - tool_name="Bash", - tool_input={"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"}, - ) - session.pending_approvals.append(approval_req) - session.status = "waiting_approval" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "waiting_approval", - }) - - decision = await ws_manager.send_approval_request( - session_id, request_id, "Bash", - {"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"} - ) - - session.pending_approvals = [a for a in session.pending_approvals if a.id != request_id] - session.status = "running" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", - }) - - import json - tool_input_content = {"tool": "Bash", "input": {"command": f"echo 'Processing: {prompt}'"}, "approved": decision.get("behavior") == "allow"} - tool_msg_id = uuid4().hex - await self.p_stream_tool_input( - session_id, tool_msg_id, "Bash", - json.dumps(tool_input_content["input"], indent=2), - ) - tool_msg = Message(id=tool_msg_id, role="tool_call", content=tool_input_content, branch_id=session.active_branch_id) - session.messages.append(tool_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": tool_msg.model_dump(mode="json"), - }) - - await asyncio.sleep(1) - - if decision.get("behavior") == "allow": - tool_result = Message(role="tool_result", content=f"Processing: {prompt}", branch_id=session.active_branch_id) - session.messages.append(tool_result) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": tool_result.model_dump(mode="json"), - }) - - await asyncio.sleep(1) - - asst_text = ( - f"I've processed your request: \"{prompt}\"\n\n" - "This is a mock response because `claude-agent-sdk` is not installed. " - "Install it with `pip install claude-agent-sdk` to use real Claude Code instances.\n\n" - f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}" - ) - asst_msg_id = uuid4().hex - await self.p_stream_text(session_id, asst_msg_id, asst_text) - - asst_msg = Message(id=asst_msg_id, role="assistant", content=asst_text, branch_id=session.active_branch_id) - session.messages.append(asst_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": asst_msg.model_dump(mode="json"), - }) - - session.status = "completed" - session.closed_at = datetime.now() - # Mock branch (claude_agent_sdk missing): leave cost untouched so - # it stays at its 0.0 default. A fake nonzero value here would - # poison the cost shown in the session header during dev. The - # `_mock_run` flag is read by the close path so a mock session - # doesn't get reported to the cloud as a real one. - setattr(session, "_mock_run", True) - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "completed", - "session": session.model_dump(mode="json"), - }) - await ws_manager.send_to_session(session_id, "agent:cost_update", { - "session_id": session_id, - "cost_usd": session.cost_usd, - }) - @staticmethod @typechecked async def invoke_agent( diff --git a/backend/apps/agents/manager/MockAgentMixin.py b/backend/apps/agents/manager/MockAgentMixin.py new file mode 100644 index 00000000..6a42c0b9 --- /dev/null +++ b/backend/apps/agents/manager/MockAgentMixin.py @@ -0,0 +1,113 @@ +"""The no-SDK mock agent loop, split out of AgentLaunchMixin so each file is one concern. This +fires only when claude_agent_sdk isn't installed (dev fallback): it fakes one Bash approval + +tool-result + assistant reply so the UI is exercisable without a real model. self.p_stream_* / +self.sessions resolve across the MRO exactly as when this lived on AgentLaunchMixin.""" + +import asyncio +import json +import logging +from datetime import datetime +from uuid import uuid4 + +from typeguard import typechecked + +from backend.apps.agents.core.models import ApprovalRequest, Message +from backend.apps.agents.core.ws_manager import ws_manager + +logger = logging.getLogger(__name__) + + +class MockAgentMixin: + @typechecked + async def p_run_mock_agent(self, session_id: str, prompt: str): + """Mock agent loop for development without claude_agent_sdk installed.""" + session = self.sessions.get(session_id) + if not session: + return + + await asyncio.sleep(1) + + request_id = uuid4().hex + approval_req = ApprovalRequest( + id=request_id, + session_id=session_id, + tool_name="Bash", + tool_input={"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"}, + ) + session.pending_approvals.append(approval_req) + session.status = "waiting_approval" + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, + "status": "waiting_approval", + }) + + decision = await ws_manager.send_approval_request( + session_id, request_id, "Bash", + {"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"} + ) + + session.pending_approvals = [a for a in session.pending_approvals if a.id != request_id] + session.status = "running" + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, + "status": "running", + }) + + tool_input_content = {"tool": "Bash", "input": {"command": f"echo 'Processing: {prompt}'"}, "approved": decision.get("behavior") == "allow"} + tool_msg_id = uuid4().hex + await self.p_stream_tool_input( + session_id, tool_msg_id, "Bash", + json.dumps(tool_input_content["input"], indent=2), + ) + tool_msg = Message(id=tool_msg_id, role="tool_call", content=tool_input_content, branch_id=session.active_branch_id) + session.messages.append(tool_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": tool_msg.model_dump(mode="json"), + }) + + await asyncio.sleep(1) + + if decision.get("behavior") == "allow": + tool_result = Message(role="tool_result", content=f"Processing: {prompt}", branch_id=session.active_branch_id) + session.messages.append(tool_result) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": tool_result.model_dump(mode="json"), + }) + + await asyncio.sleep(1) + + asst_text = ( + f"I've processed your request: \"{prompt}\"\n\n" + "This is a mock response because `claude-agent-sdk` is not installed. " + "Install it with `pip install claude-agent-sdk` to use real Claude Code instances.\n\n" + f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}" + ) + asst_msg_id = uuid4().hex + await self.p_stream_text(session_id, asst_msg_id, asst_text) + + asst_msg = Message(id=asst_msg_id, role="assistant", content=asst_text, branch_id=session.active_branch_id) + session.messages.append(asst_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": asst_msg.model_dump(mode="json"), + }) + + session.status = "completed" + session.closed_at = datetime.now() + # Mock branch (claude_agent_sdk missing): leave cost untouched so + # it stays at its 0.0 default. A fake nonzero value here would + # poison the cost shown in the session header during dev. The + # `_mock_run` flag is read by the close path so a mock session + # doesn't get reported to the cloud as a real one. + setattr(session, "_mock_run", True) + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, + "status": "completed", + "session": session.model_dump(mode="json"), + }) + await ws_manager.send_to_session(session_id, "agent:cost_update", { + "session_id": session_id, + "cost_usd": session.cost_usd, + })