feat: mark-colony-spawned for a session that created colony
This commit is contained in:
@@ -371,6 +371,7 @@ async def llm_compact(
|
||||
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
|
||||
max_depth: int = LLM_COMPACT_MAX_DEPTH,
|
||||
max_context_tokens: int = 128_000,
|
||||
preserve_user_messages: bool = False,
|
||||
) -> str:
|
||||
"""Summarise *messages* with LLM, splitting recursively if too large.
|
||||
|
||||
@@ -378,6 +379,11 @@ async def llm_compact(
|
||||
rejects the call with a context-length error, the messages are split
|
||||
in half and each half is summarised independently. Tool history is
|
||||
appended once at the top-level call (``_depth == 0``).
|
||||
|
||||
When ``preserve_user_messages`` is True, the prompt and system message
|
||||
are amplified to instruct the LLM to keep every user message verbatim
|
||||
and in full — used by the manual /compact-and-fork endpoint where the
|
||||
user wants their voice carried into the new session intact.
|
||||
"""
|
||||
from framework.agent_loop.conversation import extract_tool_call_history
|
||||
from framework.agent_loop.internals.tool_result_handler import is_context_too_large_error
|
||||
@@ -401,6 +407,7 @@ async def llm_compact(
|
||||
char_limit=char_limit,
|
||||
max_depth=max_depth,
|
||||
max_context_tokens=max_context_tokens,
|
||||
preserve_user_messages=preserve_user_messages,
|
||||
)
|
||||
else:
|
||||
prompt = build_llm_compaction_prompt(
|
||||
@@ -408,17 +415,30 @@ async def llm_compact(
|
||||
accumulator,
|
||||
formatted,
|
||||
max_context_tokens=max_context_tokens,
|
||||
preserve_user_messages=preserve_user_messages,
|
||||
)
|
||||
if preserve_user_messages:
|
||||
system_msg = (
|
||||
"You are a conversation compactor for an AI agent. "
|
||||
"Write a detailed summary that allows the agent to "
|
||||
"continue its work. CRITICAL: reproduce every user "
|
||||
"message verbatim and in full inside the 'User Messages' "
|
||||
"section — do not paraphrase, truncate, or merge them. "
|
||||
"Assistant turns and tool results may be summarised, but "
|
||||
"user input is sacred."
|
||||
)
|
||||
else:
|
||||
system_msg = (
|
||||
"You are a conversation compactor for an AI agent. "
|
||||
"Write a detailed summary that allows the agent to "
|
||||
"continue its work. Preserve user-stated rules, "
|
||||
"constraints, and account/identity preferences verbatim."
|
||||
)
|
||||
summary_budget = max(1024, max_context_tokens // 2)
|
||||
try:
|
||||
response = await ctx.llm.acomplete(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
system=(
|
||||
"You are a conversation compactor for an AI agent. "
|
||||
"Write a detailed summary that allows the agent to "
|
||||
"continue its work. Preserve user-stated rules, "
|
||||
"constraints, and account/identity preferences verbatim."
|
||||
),
|
||||
system=system_msg,
|
||||
max_tokens=summary_budget,
|
||||
)
|
||||
summary = response.content
|
||||
@@ -437,6 +457,7 @@ async def llm_compact(
|
||||
char_limit=char_limit,
|
||||
max_depth=max_depth,
|
||||
max_context_tokens=max_context_tokens,
|
||||
preserve_user_messages=preserve_user_messages,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
@@ -459,6 +480,7 @@ async def _llm_compact_split(
|
||||
char_limit: int = LLM_COMPACT_CHAR_LIMIT,
|
||||
max_depth: int = LLM_COMPACT_MAX_DEPTH,
|
||||
max_context_tokens: int = 128_000,
|
||||
preserve_user_messages: bool = False,
|
||||
) -> str:
|
||||
"""Split messages in half and summarise each half independently."""
|
||||
mid = max(1, len(messages) // 2)
|
||||
@@ -470,6 +492,7 @@ async def _llm_compact_split(
|
||||
char_limit=char_limit,
|
||||
max_depth=max_depth,
|
||||
max_context_tokens=max_context_tokens,
|
||||
preserve_user_messages=preserve_user_messages,
|
||||
)
|
||||
s2 = await llm_compact(
|
||||
ctx,
|
||||
@@ -479,6 +502,7 @@ async def _llm_compact_split(
|
||||
char_limit=char_limit,
|
||||
max_depth=max_depth,
|
||||
max_context_tokens=max_context_tokens,
|
||||
preserve_user_messages=preserve_user_messages,
|
||||
)
|
||||
return s1 + "\n\n" + s2
|
||||
|
||||
@@ -510,6 +534,7 @@ def build_llm_compaction_prompt(
|
||||
formatted_messages: str,
|
||||
*,
|
||||
max_context_tokens: int = 128_000,
|
||||
preserve_user_messages: bool = False,
|
||||
) -> str:
|
||||
"""Build prompt for LLM compaction targeting 50% of token budget.
|
||||
|
||||
@@ -539,6 +564,18 @@ def build_llm_compaction_prompt(
|
||||
target_chars = target_tokens * 4
|
||||
node_ctx = "\n".join(ctx_lines)
|
||||
|
||||
user_messages_section = (
|
||||
"6. **User Messages** — Reproduce EVERY user message verbatim and "
|
||||
"in full, in chronological order, each on its own line prefixed "
|
||||
"with the message index (e.g. \"[U1] ...\"). Do NOT paraphrase, "
|
||||
"summarise, merge, or omit any user message. Preserve markdown, "
|
||||
"code fences, whitespace, and punctuation exactly as the user "
|
||||
"wrote them.\n"
|
||||
if preserve_user_messages
|
||||
else "6. **User Messages** — Preserve ALL user-stated rules, constraints, "
|
||||
"identity preferences, and account details verbatim.\n"
|
||||
)
|
||||
|
||||
return (
|
||||
"You are compacting an AI agent's conversation history. "
|
||||
"The agent is still working and needs to continue.\n\n"
|
||||
@@ -559,8 +596,7 @@ def build_llm_compaction_prompt(
|
||||
"resolved. Include root causes so the agent doesn't repeat them.\n"
|
||||
"5. **Problem Solving Efforts** — Approaches tried, dead ends hit, "
|
||||
"and reasoning behind the current strategy.\n"
|
||||
"6. **User Messages** — Preserve ALL user-stated rules, constraints, "
|
||||
"identity preferences, and account details verbatim.\n"
|
||||
f"{user_messages_section}"
|
||||
"7. **Pending Tasks** — Work remaining, outputs still needed, and "
|
||||
"any blockers.\n"
|
||||
"8. **Current Work** — The most recent action taken and the immediate "
|
||||
|
||||
@@ -217,6 +217,25 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
logger.debug("[handle_chat] Session resolution failed: %s", err)
|
||||
return err
|
||||
|
||||
# Sessions that have spawned a colony are locked: the user must compact +
|
||||
# fork into a fresh session before continuing the conversation. Frontend
|
||||
# surfaces this as a button instead of the textarea, but enforce server-
|
||||
# side too so the lock can't be bypassed by a stale tab or scripted call.
|
||||
if getattr(session, "colony_spawned", False):
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "session_locked",
|
||||
"reason": "colony_spawned",
|
||||
"spawned_colony_name": getattr(session, "spawned_colony_name", None),
|
||||
"message": (
|
||||
"This session is locked because a colony has been "
|
||||
"spawned from it. Compact and start a new session "
|
||||
"with the same queen to continue."
|
||||
),
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
body = await request.json()
|
||||
message = body.get("message", "")
|
||||
display_message = body.get("display_message")
|
||||
@@ -663,6 +682,262 @@ async def handle_cancel_queen(request: web.Request) -> web.Response:
|
||||
return web.json_response({"cancelled": True})
|
||||
|
||||
|
||||
async def handle_mark_colony_spawned(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/mark-colony-spawned -- lock the queen DM.
|
||||
|
||||
Called by the frontend the first time the user clicks the
|
||||
COLONY_CREATED system message. Persists ``colony_spawned: true`` and
|
||||
``spawned_colony_name`` into the queen session's ``meta.json`` so the
|
||||
lock survives server restart, and caches the same on the live Session
|
||||
object so subsequent /chat calls in this process can be rejected
|
||||
immediately without disk I/O.
|
||||
|
||||
Body: ``{"colony_name": "..."}``
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
body = await request.json() if request.can_read_body else {}
|
||||
colony_name = (body.get("colony_name") or "").strip()
|
||||
if not colony_name:
|
||||
return web.json_response({"error": "colony_name is required"}, status=400)
|
||||
|
||||
queen_dir = getattr(session, "queen_dir", None)
|
||||
if queen_dir is None:
|
||||
return web.json_response(
|
||||
{"error": "queen session directory is not set on this session"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
meta_path = queen_dir / "meta.json"
|
||||
meta: dict = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
meta = {}
|
||||
|
||||
meta["colony_spawned"] = True
|
||||
meta["spawned_colony_name"] = colony_name
|
||||
meta["spawned_colony_at"] = _dt.now(UTC).isoformat()
|
||||
|
||||
try:
|
||||
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta_path.write_text(json.dumps(meta), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.exception("mark_colony_spawned: failed to persist meta.json")
|
||||
return web.json_response({"error": f"failed to persist: {exc}"}, status=500)
|
||||
|
||||
session.colony_spawned = True
|
||||
session.spawned_colony_name = colony_name
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"session_id": session.id,
|
||||
"colony_spawned": True,
|
||||
"spawned_colony_name": colony_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_compact_and_fork(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/compact-and-fork -- compact + new session.
|
||||
|
||||
The locked-by-colony-spawn UI calls this when the user clicks "compact
|
||||
+ start a new session with the same queen". The flow:
|
||||
|
||||
1. Read the queen's full conversation off disk.
|
||||
2. Run the LLM compactor with ``preserve_user_messages=True`` so every
|
||||
user turn survives verbatim into the summary.
|
||||
3. Mint a fresh session ID, copy the old queen-session dir to the new
|
||||
location, then replace the conversation parts with a single
|
||||
summary message and reset the cursor. The new session starts with
|
||||
a clean event log.
|
||||
4. Spin up a live session bound to the new dir using the same queen
|
||||
identity. The OLD session stays alive but locked; the user
|
||||
navigates to the new session in the response.
|
||||
"""
|
||||
import asyncio
|
||||
import shutil
|
||||
import time as _time
|
||||
from datetime import datetime as _dt
|
||||
|
||||
from framework.agent_loop.conversation import Message
|
||||
from framework.agent_loop.internals.compaction import llm_compact
|
||||
from framework.agent_loop.types import AgentContext
|
||||
from framework.server.session_manager import (
|
||||
_generate_session_id,
|
||||
_queen_session_dir,
|
||||
)
|
||||
from framework.storage.conversation_store import FileConversationStore
|
||||
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
queen_dir = getattr(session, "queen_dir", None)
|
||||
if queen_dir is None or not queen_dir.exists():
|
||||
return web.json_response(
|
||||
{"error": "queen session directory not found"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
queen_executor = getattr(session, "queen_executor", None)
|
||||
if queen_executor is None:
|
||||
return web.json_response({"error": "queen is not running"}, status=503)
|
||||
queen_node = queen_executor.node_registry.get("queen") if queen_executor else None
|
||||
queen_ctx: AgentContext | None = getattr(queen_node, "_last_ctx", None) if queen_node else None
|
||||
if queen_ctx is None or queen_ctx.llm is None:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"queen context not yet stamped (no LLM available for "
|
||||
"compaction). Send a message to the queen and retry."
|
||||
)
|
||||
},
|
||||
status=503,
|
||||
)
|
||||
|
||||
queen_name = session.queen_name or "default"
|
||||
convs_dir = queen_dir / "conversations"
|
||||
if not convs_dir.exists():
|
||||
return web.json_response(
|
||||
{"error": "queen has no conversation history yet"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
src_store = FileConversationStore(convs_dir)
|
||||
raw_parts = await src_store.read_parts()
|
||||
messages: list[Message] = []
|
||||
for part in raw_parts:
|
||||
try:
|
||||
messages.append(Message.from_storage_dict(part))
|
||||
except (KeyError, TypeError):
|
||||
# Skip malformed parts rather than failing the whole compaction;
|
||||
# the summary will note the gap if needed.
|
||||
logger.warning("compact_and_fork: skipping malformed part %r", part)
|
||||
continue
|
||||
if not messages:
|
||||
return web.json_response(
|
||||
{"error": "queen conversation is empty -- nothing to compact"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Run the LLM compactor with preserve_user_messages so the new session
|
||||
# carries every user turn forward. Failures here are user-visible
|
||||
# because the whole point of the action is the compacted summary.
|
||||
try:
|
||||
max_ctx_tokens = 180_000
|
||||
loop_cfg = getattr(queen_node, "_config", None)
|
||||
if loop_cfg is not None and getattr(loop_cfg, "max_context_tokens", None):
|
||||
max_ctx_tokens = int(loop_cfg.max_context_tokens)
|
||||
summary = await llm_compact(
|
||||
queen_ctx,
|
||||
messages,
|
||||
accumulator=None,
|
||||
max_context_tokens=max_ctx_tokens,
|
||||
preserve_user_messages=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("compact_and_fork: llm_compact failed")
|
||||
return web.json_response(
|
||||
{"error": f"compaction failed: {exc}"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
new_session_id = _generate_session_id()
|
||||
new_dir = _queen_session_dir(new_session_id, queen_name)
|
||||
if new_dir.exists():
|
||||
# Defensively: same-second collision would clobber another session.
|
||||
return web.json_response(
|
||||
{"error": f"new session dir collision: {new_dir}"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(shutil.copytree, queen_dir, new_dir)
|
||||
except OSError as exc:
|
||||
logger.exception("compact_and_fork: copytree failed")
|
||||
return web.json_response(
|
||||
{"error": f"failed to fork session dir: {exc}"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
# Replace parts/ with the single compacted summary; clear partials and
|
||||
# the event log so the new session presents a clean SSE replay.
|
||||
new_parts_dir = new_dir / "conversations" / "parts"
|
||||
new_partials_dir = new_dir / "conversations" / "partials"
|
||||
new_events_path = new_dir / "events.jsonl"
|
||||
|
||||
def _cleanup_forked_dir() -> None:
|
||||
if new_parts_dir.exists():
|
||||
shutil.rmtree(new_parts_dir)
|
||||
if new_partials_dir.exists():
|
||||
shutil.rmtree(new_partials_dir)
|
||||
if new_events_path.exists():
|
||||
new_events_path.unlink()
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_cleanup_forked_dir)
|
||||
except OSError:
|
||||
logger.warning("compact_and_fork: cleanup of forked dir partial-failed", exc_info=True)
|
||||
|
||||
# Write the summary as message seq 0 so the new queen wakes up with a
|
||||
# single user-role primer that contains everything she needs.
|
||||
summary_msg = Message(seq=0, role="user", content=summary)
|
||||
dest_store = FileConversationStore(new_dir / "conversations")
|
||||
await dest_store.write_part(0, summary_msg.to_storage_dict())
|
||||
await dest_store.write_cursor({"next_seq": 1})
|
||||
|
||||
# Update meta.json: clear the lock and record provenance.
|
||||
new_meta_path = new_dir / "meta.json"
|
||||
new_meta: dict = {}
|
||||
if new_meta_path.exists():
|
||||
try:
|
||||
new_meta = json.loads(new_meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
new_meta = {}
|
||||
new_meta.pop("colony_spawned", None)
|
||||
new_meta.pop("spawned_colony_name", None)
|
||||
new_meta.pop("spawned_colony_at", None)
|
||||
new_meta["queen_id"] = queen_name
|
||||
new_meta["compacted_from"] = session.id
|
||||
new_meta["compacted_at"] = _dt.now(UTC).isoformat()
|
||||
new_meta["created_at"] = _time.time()
|
||||
try:
|
||||
new_meta_path.write_text(json.dumps(new_meta), encoding="utf-8")
|
||||
except OSError:
|
||||
logger.warning("compact_and_fork: failed to write new meta.json", exc_info=True)
|
||||
|
||||
manager: Any = request.app["manager"]
|
||||
try:
|
||||
new_session = await manager.create_session(
|
||||
session_id=None,
|
||||
queen_resume_from=new_session_id,
|
||||
queen_name=queen_name,
|
||||
initial_phase="independent",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("compact_and_fork: create_session failed for forked id %s", new_session_id)
|
||||
return web.json_response(
|
||||
{"error": f"failed to start forked session: {exc}"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"new_session_id": new_session.id,
|
||||
"queen_id": queen_name,
|
||||
"compacted_from": session.id,
|
||||
"summary_chars": len(summary),
|
||||
"messages_compacted": len(messages),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_colony_spawn(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/colony-spawn -- fork queen session into a colony.
|
||||
|
||||
@@ -1085,3 +1360,11 @@ def register_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/sessions/{session_id}/replay", handle_replay)
|
||||
app.router.add_get("/api/sessions/{session_id}/goal-progress", handle_goal_progress)
|
||||
app.router.add_post("/api/sessions/{session_id}/colony-spawn", handle_colony_spawn)
|
||||
app.router.add_post(
|
||||
"/api/sessions/{session_id}/mark-colony-spawned",
|
||||
handle_mark_colony_spawned,
|
||||
)
|
||||
app.router.add_post(
|
||||
"/api/sessions/{session_id}/compact-and-fork",
|
||||
handle_compact_and_fork,
|
||||
)
|
||||
|
||||
@@ -63,6 +63,8 @@ def _session_to_live_dict(session) -> dict:
|
||||
"queen_supports_images": supports_image_tool_results(queen_model) if queen_model else True,
|
||||
"queen_id": getattr(phase_state, "queen_id", None) if phase_state else None,
|
||||
"queen_name": (phase_state.queen_profile or {}).get("name") if phase_state else None,
|
||||
"colony_spawned": getattr(session, "colony_spawned", False),
|
||||
"spawned_colony_name": getattr(session, "spawned_colony_name", None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ class Session:
|
||||
# tool unlocked. The mode is the canonical discriminator for storage
|
||||
# path, tool exposure, and SSE filtering — see the Phase 2 plan.
|
||||
mode: Literal["dm", "colony"] = "dm"
|
||||
# Set to True after the user clicks the COLONY_CREATED system message
|
||||
# in this DM. Locks the chat input — the user must compact+fork into a
|
||||
# fresh session before continuing the conversation. Persisted in
|
||||
# meta.json so the lock survives server restarts.
|
||||
colony_spawned: bool = False
|
||||
spawned_colony_name: str | None = None
|
||||
|
||||
|
||||
class SessionManager:
|
||||
@@ -1319,6 +1325,13 @@ class SessionManager:
|
||||
_new_meta["agent_path"] = str(session.worker_path)
|
||||
_existing_meta.update(_new_meta)
|
||||
_meta_path.write_text(json.dumps(_existing_meta), encoding="utf-8")
|
||||
# Hydrate colony-spawned lock state from meta.json so the lock
|
||||
# survives server restart / cold-resume into a live session.
|
||||
if _existing_meta.get("colony_spawned") is True:
|
||||
session.colony_spawned = True
|
||||
_spawned_name = _existing_meta.get("spawned_colony_name")
|
||||
if isinstance(_spawned_name, str):
|
||||
session.spawned_colony_name = _spawned_name
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -1694,6 +1707,8 @@ class SessionManager:
|
||||
# Read extra metadata written at session start
|
||||
agent_name: str | None = None
|
||||
agent_path: str | None = None
|
||||
colony_spawned: bool = False
|
||||
spawned_colony_name: str | None = None
|
||||
meta_path = queen_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
@@ -1701,6 +1716,10 @@ class SessionManager:
|
||||
agent_name = meta.get("agent_name")
|
||||
agent_path = meta.get("agent_path")
|
||||
created_at = meta.get("created_at") or created_at
|
||||
colony_spawned = bool(meta.get("colony_spawned"))
|
||||
_spawned = meta.get("spawned_colony_name")
|
||||
if isinstance(_spawned, str):
|
||||
spawned_colony_name = _spawned
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
@@ -1712,6 +1731,8 @@ class SessionManager:
|
||||
"created_at": created_at,
|
||||
"agent_name": agent_name,
|
||||
"agent_path": agent_path,
|
||||
"colony_spawned": colony_spawned,
|
||||
"spawned_colony_name": spawned_colony_name,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -74,4 +74,28 @@ export const executionApi = {
|
||||
`/sessions/${sessionId}/colony-spawn`,
|
||||
{ colony_name: colonyName, task },
|
||||
),
|
||||
|
||||
/** Lock a queen DM session because the user opened a spawned colony.
|
||||
* After this call /chat returns 409 until compactAndFork creates a new session.
|
||||
*/
|
||||
markColonySpawned: (sessionId: string, colonyName: string) =>
|
||||
api.post<{
|
||||
session_id: string;
|
||||
colony_spawned: boolean;
|
||||
spawned_colony_name: string;
|
||||
}>(`/sessions/${sessionId}/mark-colony-spawned`, {
|
||||
colony_name: colonyName,
|
||||
}),
|
||||
|
||||
/** Compact the locked session and fork into a fresh session under the same queen.
|
||||
* Returns the new session ID; the frontend should navigate the user to it.
|
||||
*/
|
||||
compactAndFork: (sessionId: string) =>
|
||||
api.post<{
|
||||
new_session_id: string;
|
||||
queen_id: string;
|
||||
compacted_from: string;
|
||||
summary_chars: number;
|
||||
messages_compacted: number;
|
||||
}>(`/sessions/${sessionId}/compact-and-fork`),
|
||||
};
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface LiveSession {
|
||||
queen_id?: string | null;
|
||||
/** Selected queen display name (e.g. "Alexandra") */
|
||||
queen_name?: string | null;
|
||||
/** True after the user has clicked into a colony spawned from this DM —
|
||||
* /chat returns 409 until the user compacts and forks into a new session. */
|
||||
colony_spawned?: boolean;
|
||||
/** Name of the colony that locked this session (set with colony_spawned). */
|
||||
spawned_colony_name?: string | null;
|
||||
/** Present in 409 conflict responses when worker is still loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,22 @@ interface ChatPanelProps {
|
||||
queenProfileId?: string | null;
|
||||
/** Queen ID — used to display the queen's avatar photo in messages */
|
||||
queenId?: string;
|
||||
/** Called when the user clicks a `colony_link` system message. Receives
|
||||
* the colony name. The parent should call markColonySpawned + flip
|
||||
* ``colonySpawned`` to lock the input. The Link still navigates. */
|
||||
onColonyLinkClick?: (colonyName: string) => void;
|
||||
/** When true, the composer is replaced with a "compact + new session"
|
||||
* button — set by the parent after the user opens a spawned colony. */
|
||||
colonySpawned?: boolean;
|
||||
/** Name of the colony that locked this DM (shown on the locked button). */
|
||||
spawnedColonyName?: string | null;
|
||||
/** Display label for the queen on the locked button (e.g. "Charlotte"). */
|
||||
queenDisplayName?: string;
|
||||
/** Called when the user clicks the locked-state button. Should compact
|
||||
* the current session and navigate to the new one. */
|
||||
onCompactAndFork?: () => void;
|
||||
/** When true, disable the compact-and-fork button (request in flight). */
|
||||
compactingAndForking?: boolean;
|
||||
}
|
||||
|
||||
const queenColor = "hsl(45,95%,58%)";
|
||||
@@ -482,12 +498,14 @@ const MessageBubble = memo(
|
||||
showQueenPhaseBadge = true,
|
||||
queenProfileId,
|
||||
queenAvatarUrl,
|
||||
onColonyLinkClick,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
queenPhase?: "independent" | "working" | "reviewing";
|
||||
showQueenPhaseBadge?: boolean;
|
||||
queenProfileId?: string | null;
|
||||
queenAvatarUrl?: string | null;
|
||||
onColonyLinkClick?: (colonyName: string) => void;
|
||||
}) {
|
||||
const isUser = msg.type === "user";
|
||||
const isQueen = msg.role === "queen";
|
||||
@@ -535,7 +553,9 @@ const MessageBubble = memo(
|
||||
if (msg.type === "colony_link") {
|
||||
// Rendered when the queen calls create_colony() and the backend
|
||||
// emits a COLONY_CREATED event. Gives the user a clickable card
|
||||
// that navigates to the new colony page.
|
||||
// that navigates to the new colony page. Clicking also locks the
|
||||
// queen DM (mark-colony-spawned) so the user must compact + fork
|
||||
// before continuing this conversation.
|
||||
let parsed: {
|
||||
colony_name?: string;
|
||||
is_new?: boolean;
|
||||
@@ -557,6 +577,11 @@ const MessageBubble = memo(
|
||||
<div className="flex justify-center py-2">
|
||||
<Link
|
||||
to={href}
|
||||
onClick={() => {
|
||||
if (colonyName && onColonyLinkClick) {
|
||||
onColonyLinkClick(colonyName);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-2 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 px-4 py-2 rounded-full border border-primary/20 transition-colors"
|
||||
>
|
||||
<span>🏛️</span>
|
||||
@@ -711,6 +736,12 @@ export default function ChatPanel({
|
||||
initialDraft,
|
||||
queenProfileId,
|
||||
queenId,
|
||||
onColonyLinkClick,
|
||||
colonySpawned,
|
||||
spawnedColonyName,
|
||||
queenDisplayName,
|
||||
onCompactAndFork,
|
||||
compactingAndForking,
|
||||
}: ChatPanelProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||
@@ -1151,6 +1182,7 @@ export default function ChatPanel({
|
||||
showQueenPhaseBadge={showQueenPhaseBadge}
|
||||
queenProfileId={queenProfileId}
|
||||
queenAvatarUrl={queenAvatarUrl}
|
||||
onColonyLinkClick={onColonyLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1308,8 +1340,43 @@ export default function ChatPanel({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Input area — question widget replaces textarea when a question is pending */}
|
||||
{pendingQuestions &&
|
||||
{/* Input area — colony-spawned lock replaces everything; question widget
|
||||
replaces textarea when a question is pending */}
|
||||
{colonySpawned ? (
|
||||
<div className="p-4 border-t border-border/50 bg-muted/20">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
This conversation spawned colony{" "}
|
||||
{spawnedColonyName ? (
|
||||
<strong className="text-foreground">{spawnedColonyName}</strong>
|
||||
) : (
|
||||
"a colony"
|
||||
)}
|
||||
. To keep chatting with{" "}
|
||||
{queenDisplayName || "this queen"}, compact this session and start
|
||||
a fresh one.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCompactAndFork}
|
||||
disabled={!onCompactAndFork || compactingAndForking}
|
||||
className="inline-flex items-center gap-2 text-xs font-medium text-primary-foreground bg-primary hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-2 rounded-full transition-opacity"
|
||||
>
|
||||
{compactingAndForking ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Compacting…</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
Compact & start new session
|
||||
{queenDisplayName ? ` with ${queenDisplayName}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : pendingQuestions &&
|
||||
pendingQuestions.length >= 2 &&
|
||||
onMultiQuestionSubmit ? (
|
||||
<MultiQuestionWidget
|
||||
|
||||
@@ -59,6 +59,15 @@ export default function QueenDM() {
|
||||
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
|
||||
const [cloneColonyName, setCloneColonyName] = useState("");
|
||||
const [cloneTask, setCloneTask] = useState("");
|
||||
// Colony-spawned lock state. Once a colony has been spawned from this DM
|
||||
// and the user clicked into it, /chat is rejected server-side and the
|
||||
// composer is replaced with a "compact + new session" button. Hydrated
|
||||
// from the session detail and updated optimistically on click.
|
||||
const [colonySpawned, setColonySpawned] = useState(false);
|
||||
const [spawnedColonyName, setSpawnedColonyName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [compactingAndForking, setCompactingAndForking] = useState(false);
|
||||
|
||||
const turnCounterRef = useRef(0);
|
||||
// Maps tool_use_id → the pill message ID and tool name that was created for it.
|
||||
@@ -85,6 +94,9 @@ export default function QueenDM() {
|
||||
setActiveToolCalls({});
|
||||
setQueenPhase("independent");
|
||||
setInitialDraft(null);
|
||||
setColonySpawned(false);
|
||||
setSpawnedColonyName(null);
|
||||
setCompactingAndForking(false);
|
||||
turnCounterRef.current = 0;
|
||||
toolUseToPillRef.current = {};
|
||||
queenIterTextRef.current = {};
|
||||
@@ -305,6 +317,65 @@ export default function QueenDM() {
|
||||
};
|
||||
}, [queenId, sessionId]);
|
||||
|
||||
// Hydrate the colony-spawned lock from the session detail whenever the
|
||||
// session ID changes. The /sessions/{id} response carries colony_spawned
|
||||
// (live) and the cold-info path returns the same field after a server
|
||||
// restart, so the same fetch covers both states.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
sessionsApi
|
||||
.get(sessionId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const locked = Boolean(
|
||||
(data as { colony_spawned?: boolean }).colony_spawned,
|
||||
);
|
||||
const name =
|
||||
(data as { spawned_colony_name?: string | null })
|
||||
.spawned_colony_name ?? null;
|
||||
setColonySpawned(locked);
|
||||
setSpawnedColonyName(name);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal — lock simply won't activate until the user navigates back.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const handleColonyLinkClick = useCallback(
|
||||
(colonyName: string) => {
|
||||
if (!sessionId || !colonyName) return;
|
||||
// Optimistically lock so the textarea swaps to the button before the
|
||||
// user navigates back. Backend persists the same flag in meta.json so
|
||||
// a refresh would re-hydrate the locked state anyway.
|
||||
setColonySpawned(true);
|
||||
setSpawnedColonyName(colonyName);
|
||||
executionApi.markColonySpawned(sessionId, colonyName).catch(() => {
|
||||
// Revert on failure so the user isn't stranded with no composer.
|
||||
setColonySpawned(false);
|
||||
setSpawnedColonyName(null);
|
||||
});
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const handleCompactAndFork = useCallback(async () => {
|
||||
if (!sessionId || compactingAndForking || !queenId) return;
|
||||
setCompactingAndForking(true);
|
||||
try {
|
||||
const result = await executionApi.compactAndFork(sessionId);
|
||||
// Navigate to the freshly-forked session for the same queen. Replacing
|
||||
// the URL keeps the back button on the home/history page rather than
|
||||
// bouncing back to the now-locked DM.
|
||||
setSearchParams({ session: result.new_session_id }, { replace: true });
|
||||
} catch {
|
||||
setCompactingAndForking(false);
|
||||
}
|
||||
}, [sessionId, compactingAndForking, queenId, setSearchParams]);
|
||||
|
||||
const handleColonySpawn = useCallback(async () => {
|
||||
if (!sessionId || spawning) return;
|
||||
const colony = cloneColonyName.trim();
|
||||
@@ -856,6 +927,12 @@ export default function QueenDM() {
|
||||
initialDraft={initialDraft}
|
||||
queenProfileId={queenId ?? null}
|
||||
queenId={queenId}
|
||||
onColonyLinkClick={handleColonyLinkClick}
|
||||
colonySpawned={colonySpawned}
|
||||
spawnedColonyName={spawnedColonyName}
|
||||
queenDisplayName={queenName}
|
||||
onCompactAndFork={handleCompactAndFork}
|
||||
compactingAndForking={compactingAndForking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user