diff --git a/core/framework/server/routes_queens.py b/core/framework/server/routes_queens.py index 201943c4..24a31d87 100644 --- a/core/framework/server/routes_queens.py +++ b/core/framework/server/routes_queens.py @@ -378,6 +378,8 @@ async def handle_select_queen_session(request: web.Request) -> web.Response: async def handle_new_queen_session(request: web.Request) -> web.Response: """POST /api/queen/{queen_id}/session/new -- create a fresh queen session.""" + from framework.tools.queen_lifecycle_tools import QUEEN_PHASES + queen_id = request.match_info["queen_id"] manager = request.app["manager"] @@ -387,9 +389,25 @@ async def handle_new_queen_session(request: web.Request) -> web.Response: except FileNotFoundError: return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404) - body = await request.json() if request.can_read_body else {} + if request.can_read_body: + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "Invalid JSON body"}, status=400) + if not isinstance(body, dict): + return web.json_response({"error": "Request body must be a JSON object"}, status=400) + else: + body = {} initial_prompt = body.get("initial_prompt") initial_phase = body.get("initial_phase") or "independent" + if initial_phase not in QUEEN_PHASES: + return web.json_response( + { + "error": f"Invalid initial_phase '{initial_phase}'", + "valid": sorted(QUEEN_PHASES), + }, + status=400, + ) session = await manager.create_session( initial_prompt=initial_prompt, diff --git a/core/framework/server/routes_sessions.py b/core/framework/server/routes_sessions.py index 2070acdc..26f5f32c 100644 --- a/core/framework/server/routes_sessions.py +++ b/core/framework/server/routes_sessions.py @@ -122,8 +122,19 @@ async def handle_create_session(request: web.Request) -> web.Response: (equivalent to the old POST /api/agents). Otherwise creates a queen-only session that can later have a colony loaded via POST /sessions/{id}/colony. """ + from framework.agents.queen.queen_profiles import ensure_default_queens, load_queen_profile + from framework.tools.queen_lifecycle_tools import QUEEN_PHASES + manager = _get_manager(request) - body = await request.json() if request.can_read_body else {} + if request.can_read_body: + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "Invalid JSON body"}, status=400) + if not isinstance(body, dict): + return web.json_response({"error": "Request body must be a JSON object"}, status=400) + else: + body = {} agent_path = body.get("agent_path") agent_id = body.get("agent_id") session_id = body.get("session_id") @@ -134,6 +145,21 @@ async def handle_create_session(request: web.Request) -> web.Response: initial_phase = body.get("initial_phase") worker_name = body.get("worker_name") + if initial_phase is not None and initial_phase not in QUEEN_PHASES: + return web.json_response( + { + "error": f"Invalid initial_phase '{initial_phase}'", + "valid": sorted(QUEEN_PHASES), + }, + status=400, + ) + if queen_name: + ensure_default_queens() + try: + load_queen_profile(queen_name) + except FileNotFoundError: + return web.json_response({"error": f"Queen '{queen_name}' not found"}, status=404) + if agent_path: try: agent_path = str(validate_agent_path(agent_path)) @@ -160,6 +186,7 @@ async def handle_create_session(request: web.Request) -> web.Response: model=model, initial_prompt=initial_prompt, queen_resume_from=queen_resume_from, + queen_name=queen_name, initial_phase=initial_phase, ) except ValueError as e: diff --git a/core/framework/server/routes_skills.py b/core/framework/server/routes_skills.py index 78432aed..1f33489f 100644 --- a/core/framework/server/routes_skills.py +++ b/core/framework/server/routes_skills.py @@ -309,14 +309,20 @@ def _effective_enabled( queen_store: SkillOverrideStore | None, colony_store: SkillOverrideStore | None, ) -> bool: - # Colony explicit wins over queen explicit; either explicit wins over - # master switch + default. Keeps the UI's enable/disable toggle simple. + # Mirrors ``SkillsManager._apply_overrides`` so the UI's "enabled" column + # matches what the queen actually sees in her prompt. Colony explicit wins + # over queen explicit; either explicit wins over preset-off-by-default and + # over the ``all_defaults_disabled`` master switch. for store in (colony_store, queen_store): if store is None: continue entry = store.get(skill.name) if entry is not None and entry.enabled is not None: return entry.enabled + # Preset-scope capability packs ship OFF; they only appear in the queen's + # catalog after an explicit per-queen/colony opt-in. + if skill.source_scope == "preset": + return False for store in (colony_store, queen_store): if store is not None and store.all_defaults_disabled and skill.source_scope == "framework": return False diff --git a/core/framework/server/session_manager.py b/core/framework/server/session_manager.py index 0af5ad01..b86d0e19 100644 --- a/core/framework/server/session_manager.py +++ b/core/framework/server/session_manager.py @@ -1223,8 +1223,27 @@ class SessionManager: logger.info("Session '%s': shutdown reflection spawned", session_id) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - except Exception: - logger.warning("Session '%s': failed to spawn shutdown reflection", session_id, exc_info=True) + except RuntimeError as exc: + # Most common when a session is stopped after the event loop + # has closed (e.g. during server shutdown or from an atexit + # handler). The reflection would have had nothing to write + # anyway — no new turns since the last periodic reflection. + logger.warning( + "Session '%s': shutdown reflection skipped — event loop unavailable (%s). " + "Normal during server shutdown; anything worth persisting was saved by the " + "periodic reflection after the last turn.", + session_id, + exc, + ) + except Exception as exc: + logger.warning( + "Session '%s': failed to spawn shutdown reflection: %s: %s. " + "Check that queen_dir exists and session.llm is configured; full traceback follows.", + session_id, + type(exc).__name__, + exc, + exc_info=True, + ) if session.queen_task is not None: session.queen_task.cancel() diff --git a/core/framework/tools/queen_lifecycle_tools.py b/core/framework/tools/queen_lifecycle_tools.py index a379b968..45e18585 100644 --- a/core/framework/tools/queen_lifecycle_tools.py +++ b/core/framework/tools/queen_lifecycle_tools.py @@ -116,6 +116,9 @@ class WorkerSessionAdapter: worker_path: Path | None = None +QUEEN_PHASES: frozenset[str] = frozenset({"independent", "incubating", "working", "reviewing"}) + + @dataclass class QueenPhaseState: """Mutable state container for queen operating phase. @@ -131,7 +134,7 @@ class QueenPhaseState: that trigger phase transitions. """ - phase: str = "independent" # "independent", "incubating", "working", or "reviewing" + phase: str = "independent" # one of QUEEN_PHASES independent_tools: list = field(default_factory=list) # list[Tool] incubating_tools: list = field(default_factory=list) # list[Tool] working_tools: list = field(default_factory=list) # list[Tool]