mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] agents: launch honors its prompt instead of silently dropping it, spawn phases log their trail (the ENG-131 ghost hang)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user