fix: validate session creation inputs and tighten skill/reflection edges

This commit is contained in:
Richard Tang
2026-04-22 15:08:50 -07:00
parent e9aea0bbc4
commit b55c8fdf86
5 changed files with 80 additions and 7 deletions
+19 -1
View File
@@ -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,
+28 -1
View File
@@ -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:
+8 -2
View File
@@ -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
+21 -2
View File
@@ -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()
@@ -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]