diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 5270b927..7a46d44d 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -138,6 +138,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr # Read BEFORE build_agent_options consumes these flags: a fresh-session/fork request must force the persistent client to respawn (same branch id would otherwise fingerprint-match a client still holding the old transcript). p_force_respawn = bool(session.needs_fresh_session or session.needs_fork or fork_session) try: + logger.info(f"[SPAWN-PHASE] run-loop start session={session_id[:8]}") (options, options_kwargs, prompt_content, p_stderr_buffer, global_settings) = await self.build_agent_options( session, session_id, prompt, prompt_content, builtin_perms, @@ -148,7 +149,9 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr thinking = ThinkingState() # Gate the CLI turn (spawn + stream) behind the admission slot so a burst can't run every turn at once; the slot is held ONLY for run_turn_with_retry, so the context-valve retry below re-acquires cleanly instead of nesting. + logger.info(f"[SPAWN-PHASE] admission-wait session={session_id[:8]}") async with self.turn_admission_slot(session, session_id): + logger.info(f"[SPAWN-PHASE] admitted session={session_id[:8]}") await self.run_turn_with_retry( session, session_id, prompt_content, options, options_kwargs, turn, thinking, p_stderr_buffer, resolved_model, api_type, global_settings, diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 730882c2..d25a8da3 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -132,6 +132,9 @@ async def get_session(session_id: str): @agents.router.post("/launch") async def launch_agent(config: AgentConfig): session = await agent_manager.launch_agent(config) + # A launch that carries a prompt runs it as the first turn through the same path /message uses. + if config.prompt: + asyncio.create_task(agent_manager.send_message(session.id, config.prompt)) return {"session_id": session.id, "session": session.model_dump(mode="json")} @agents.router.post("/sessions/{session_id}/message") diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 3bec810e..a6dcfc0d 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -5,6 +5,8 @@ from uuid import uuid4 class AgentConfig(BaseModel): name: str = "" + # First-turn prompt. Launch used to silently DROP this (pydantic ignores unknown fields), leaving the session claiming "running" forever with zero messages and no error, the ENG-131 ghost hang. + prompt: Optional[str] = None model: str = "sonnet" mode: str = "agent" provider: str = "anthropic" diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index 08ce61d5..500d5f55 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -50,6 +50,8 @@ class RunOptions(AgentManagerProtocol): from claude_agent_sdk import ClaudeAgentOptions from claude_agent_sdk.types import HookMatcher + logger.info(f"[SPAWN-PHASE] options-build start session={session_id[:8]}") + # Per-SESSION hook context, updated in place each turn: with a persistent client the hooks the # CLI holds were bound at connect, so they must read this stable object, not a per-turn rebuild. hook_ctx = self.hook_ctxs.get(session_id) @@ -115,7 +117,9 @@ class RunOptions(AgentManagerProtocol): set_framework_overhead(session, composed_prompt) # Pass session.active_mcps as the activation filter. Empty list ⇒ no MCP tools shipped to the SDK; the model must MCPSearch and MCPActivate first. The product invariant lives here at the dispatch layer (see build_mcp_servers docstring). + logger.info(f"[SPAWN-PHASE] mcp-build start session={session_id[:8]}") mcp_servers = await self.build_mcp_servers(session.allowed_tools, session.active_mcps) + logger.info(f"[SPAWN-PHASE] mcp-build done session={session_id[:8]}") browser_delegation_tools, invoke_agent_tools = register_builtin_mcp_servers( mcp_servers, session, builtin_perms, selected_browser_ids, selected_app_output_ids @@ -192,9 +196,11 @@ class RunOptions(AgentManagerProtocol): "include_partial_messages": True, } # cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key. + logger.info(f"[SPAWN-PHASE] provider-env start session={session_id[:8]}") await configure_provider_env( options_kwargs, session, resolved_model, api_type, global_settings ) + logger.info(f"[SPAWN-PHASE] provider-env done session={session_id[:8]}") if mcp_servers: options_kwargs["mcp_servers"] = mcp_servers mcp_json_len = len(json.dumps({"mcpServers": mcp_servers})) @@ -264,7 +270,9 @@ class RunOptions(AgentManagerProtocol): # Distill the dropped span into a cached aux summary so a rebuild keeps the gist of old turns instead of hard-dropping them. Fail-open: "" -> the plain recap above, exactly today's behavior. from backend.apps.agents.manager.session.distill_history import distilled_history_summary from backend.apps.agents.manager.session.history_compaction import wrap_platform_note + logger.info(f"[SPAWN-PHASE] distill start session={session_id[:8]}") distilled = await distilled_history_summary(session, global_settings) + logger.info(f"[SPAWN-PHASE] distill done session={session_id[:8]}") if distilled: fenced = wrap_platform_note(f"Summary of earlier conversation (older turns compacted):\n{distilled}") history = f"{fenced}\n\n{history}" if history else fenced @@ -275,7 +283,9 @@ class RunOptions(AgentManagerProtocol): prompt_content.insert(0, {"type": "text", "text": history}) # Compaction trigger (Phase 2). Driven by live ctx_used ratio rather than turn count, fires when input_tokens/context_window crosses session.compact_threshold_pct (default 0.65). Cheap, programmatic summarization (no aux LLM call) so this adds zero latency on the user's turn. + logger.info(f"[SPAWN-PHASE] context-guard start session={session_id[:8]}") await pre_send_context_guard(self, session, session_id) + logger.info(f"[SPAWN-PHASE] context-guard done session={session_id[:8]}") logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions short={session.model} resolved={resolved_model} api_type={api_type}") options = ClaudeAgentOptions(**options_kwargs) diff --git a/backend/apps/agents/manager/run/TurnRunner.py b/backend/apps/agents/manager/run/TurnRunner.py index 90df2b49..64b0c0e5 100644 --- a/backend/apps/agents/manager/run/TurnRunner.py +++ b/backend/apps/agents/manager/run/TurnRunner.py @@ -131,10 +131,13 @@ class TurnRunner(AgentManagerProtocol): async def p_connect(): p_client = ClaudeSDKClient(options=options) + logger.info(f"[SPAWN-PHASE] cli-connect start session={session_id[:8]}") await p_client.connect() + logger.info(f"[SPAWN-PHASE] cli-connect done session={session_id[:8]}") return p_client fp = boot_fingerprint(options_kwargs, session) + logger.info(f"[SPAWN-PHASE] client-acquire start session={session_id[:8]}") handle = await acquire_client( self.client_pool, session_id, fp, p_connect, force_respawn=force_respawn, ) diff --git a/backend/tests/test_launch_with_prompt.py b/backend/tests/test_launch_with_prompt.py new file mode 100644 index 00000000..bb92b1c0 --- /dev/null +++ b/backend/tests/test_launch_with_prompt.py @@ -0,0 +1,53 @@ +"""A launch that carries a prompt must RUN it. + +The trap this seals (ENG-131): AgentConfig had no `prompt` field, so pydantic silently dropped it +from `POST /api/agents/launch`. The session was created, broadcast as "running", and then nothing +was ever scheduled: a permanent silent spinner with 0 messages, indistinguishable from a real hang. +Five sessions were forensically chased through pool, router, and scheduler before the launch body +turned out to be the whole story. +""" + +import asyncio + +from pytest import MonkeyPatch + +from backend.apps.agents import agents as agents_module +from backend.apps.agents.core.models import AgentConfig, AgentSession + + +def p_launch(monkeypatch: MonkeyPatch, config: AgentConfig) -> list: + sent: list = [] + session = AgentSession(name="probe") + + async def fake_launch(cfg: AgentConfig) -> AgentSession: + return session + + async def fake_send(session_id: str, prompt: str, **kwargs) -> None: + sent.append((session_id, prompt)) + + monkeypatch.setattr(agents_module.agent_manager, "launch_agent", fake_launch) + monkeypatch.setattr(agents_module.agent_manager, "send_message", fake_send) + + async def run() -> None: + await agents_module.launch_agent(config) + # The first turn is fire-and-forget; drain it before asserting. + await asyncio.sleep(0) + await asyncio.sleep(0) + + asyncio.run(run()) + return sent + + +def test_launch_with_prompt_schedules_the_first_turn(monkeypatch: MonkeyPatch) -> None: + sent = p_launch(monkeypatch, AgentConfig(name="probe", prompt="say ready")) + assert len(sent) == 1 + assert sent[0][1] == "say ready" + + +def test_launch_without_prompt_schedules_nothing(monkeypatch: MonkeyPatch) -> None: + assert p_launch(monkeypatch, AgentConfig(name="probe")) == [] + + +def test_prompt_survives_the_launch_body_parse() -> None: + # The original failure: this field VANISHED in validation, so the route could never see it. + assert AgentConfig(**{"prompt": "hello", "name": "x"}).prompt == "hello"