Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3fd2ad650 | |||
| 626f9479c8 |
@@ -216,6 +216,28 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
desc = data.get("description", desc)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Try flowchart.json (draft agent) for metadata
|
||||
flowchart_path = path / "flowchart.json"
|
||||
if flowchart_path.exists():
|
||||
try:
|
||||
data = json.loads(
|
||||
flowchart_path.read_text(encoding="utf-8"),
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
# Use draft_graph metadata if available
|
||||
draft = data.get("draft_graph", data)
|
||||
if isinstance(draft, dict):
|
||||
raw_name = draft.get("agent_name", name)
|
||||
if raw_name:
|
||||
name = raw_name.replace("_", " ").title()
|
||||
desc = draft.get("description", desc)
|
||||
# Count nodes from draft
|
||||
nodes = draft.get("nodes", [])
|
||||
if nodes:
|
||||
node_count = len(nodes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries.append(
|
||||
AgentEntry(
|
||||
|
||||
@@ -171,6 +171,10 @@ _QUEEN_INDEPENDENT_TOOLS = [
|
||||
"search_files",
|
||||
"run_command",
|
||||
"undo_changes",
|
||||
# DM mode agent building - allow drafting and colony creation
|
||||
"save_agent_draft",
|
||||
"confirm_and_build",
|
||||
"load_built_agent",
|
||||
]
|
||||
|
||||
|
||||
@@ -634,7 +638,9 @@ document, database, subprocess, etc.) with unique shapes and colors. Set \
|
||||
flowchart_type on a node to override. Nodes need only an id. \
|
||||
Use decision nodes (flowchart_type: "decision", with decision_clause and \
|
||||
labeled yes/no edges) to make conditional branching explicit. \
|
||||
hexagons — connect them as leaf nodes to their parent.
|
||||
hexagons — connect them as leaf nodes to their parent. \
|
||||
⚠️ In DM mode (independent phase), the user CANNOT see the flowchart visualizer. \
|
||||
After calling save_agent_draft(), you MUST describe the design in text to the user.
|
||||
- confirm_and_build() — Record user confirmation of the draft. Dissolves \
|
||||
planning-only nodes (decision → predecessor criteria; browser/GCU → \
|
||||
approves via ask_user.
|
||||
@@ -764,13 +770,34 @@ _queen_behavior_independent = """
|
||||
|
||||
You are the agent. No worker, no graph — you execute directly.
|
||||
1. Understand the task from the user
|
||||
2. Plan your approach briefly (no flowcharts or agent design)
|
||||
2. Plan your approach briefly
|
||||
3. Execute using your tools: file I/O, shell commands, browser automation
|
||||
4. Report results, iterate if needed
|
||||
|
||||
You have NO lifecycle tools (no start_graph, stop_graph, confirm_and_build, etc.).
|
||||
If the task requires building a dedicated agent, tell the user to start a \
|
||||
new session without independent mode.
|
||||
## Agent Building in DM Mode (INDEPENDENT Phase)
|
||||
|
||||
When the user wants to build a Hive agent in DM mode:
|
||||
1. Discuss the plan with the user (what the agent should do, what tools it needs)
|
||||
2. Call save_agent_draft() to save the design to the system
|
||||
3. **DESCRIBE THE DESIGN IN TEXT to the user** - The user CANNOT see any visual flowchart in DM mode! You must tell them:
|
||||
- Agent name
|
||||
- What it does (the goal)
|
||||
- Key nodes/steps in plain English
|
||||
- What tools it will use
|
||||
4. Ask for approval with: ask_user("Ready to build?", ["Approve and build", "Adjust the design"])
|
||||
5. **WHEN USER CLICKS "Approve and build" - YOU MUST CALL:**
|
||||
```
|
||||
confirm_and_build(agent_name="the_agent_name")
|
||||
```
|
||||
This is NOT optional. Do NOT just say you'll create the colony. You MUST call the function.
|
||||
6. The system will navigate to the colony automatically. The visual flowchart will appear there.
|
||||
|
||||
**⚠️ CRITICAL - DM Mode Limitation:**
|
||||
- The user CANNOT see the flowchart visualizer in DM mode
|
||||
- Calling save_agent_draft() does NOT show anything to the user
|
||||
- You MUST describe the design in text before asking for approval
|
||||
- **WHEN USER APPROVES, CALL confirm_and_build() IMMEDIATELY. DO NOT HESITATE.**
|
||||
- The flowchart ONLY becomes visible AFTER navigating to the colony
|
||||
"""
|
||||
|
||||
# -- Behavior shared across all phases --
|
||||
@@ -825,10 +852,19 @@ confirmation before moving to building. The sequence is:
|
||||
confirm_and_build()
|
||||
Skipping any of these steps will be blocked by the system.
|
||||
|
||||
Remember: DO NOT write or edit any files yet. This is a read-only exploration \
|
||||
and planning phase. You have read-only tools but no write/edit tools in this \
|
||||
phase. If the user asks you to write code, explain that you need to finalize \
|
||||
the plan first.
|
||||
## DM Mode Transition (Queen-only conversations)
|
||||
|
||||
When you are in a direct message conversation with the user (not in a colony):
|
||||
1. After the user approves the design with "Approve and build", you MUST \
|
||||
explain what will happen next BEFORE calling confirm_and_build()
|
||||
2. Tell the user: "Great! I'm creating a colony for this agent now. You'll be \
|
||||
taken there in a moment to complete the build."
|
||||
3. Then call confirm_and_build(agent_name) once to create the colony
|
||||
4. The system will automatically navigate the user to the new colony
|
||||
|
||||
**IMPORTANT:** Only call confirm_and_build() ONCE with the agent_name parameter. \
|
||||
Multiple calls will be ignored. The function handles both confirmation and folder \
|
||||
creation in one step when agent_name is provided.
|
||||
|
||||
## Diagnosis mode (returning from staging/running)
|
||||
|
||||
|
||||
@@ -162,6 +162,9 @@ class EventType(StrEnum):
|
||||
TRIGGER_REMOVED = "trigger_removed"
|
||||
TRIGGER_UPDATED = "trigger_updated"
|
||||
|
||||
# Colony creation requested (queen DM mode -> frontend navigation)
|
||||
COLONY_CREATION_REQUESTED = "colony_creation_requested"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
|
||||
@@ -1094,10 +1094,14 @@ def _eval_string_binop(node) -> str | None:
|
||||
|
||||
|
||||
def _is_valid_agent_dir(path: Path) -> bool:
|
||||
"""Check if a directory contains a valid agent (agent.json or agent.py)."""
|
||||
"""Check if a directory contains a valid agent (agent.json, agent.py, or flowchart.json)."""
|
||||
if not path.is_dir():
|
||||
return False
|
||||
return (path / "agent.json").exists() or (path / "agent.py").exists()
|
||||
return (
|
||||
(path / "agent.json").exists()
|
||||
or (path / "agent.py").exists()
|
||||
or (path / "flowchart.json").exists()
|
||||
)
|
||||
|
||||
|
||||
def _has_agents(directory: Path) -> bool:
|
||||
|
||||
@@ -46,12 +46,22 @@ def validate_agent_path(agent_path: str | Path) -> Path:
|
||||
restricting agent loading to known safe directories: ``exports/``,
|
||||
``examples/``, and ``~/.hive/agents/``.
|
||||
|
||||
Supports ``colonies/`` prefix as shorthand for ``~/.hive/colonies/``.
|
||||
|
||||
Returns the resolved ``Path`` on success.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is outside all allowed roots.
|
||||
"""
|
||||
resolved = Path(agent_path).expanduser().resolve()
|
||||
agent_path_str = str(agent_path)
|
||||
|
||||
# Handle colonies/ prefix as shorthand for ~/.hive/colonies/
|
||||
if agent_path_str.startswith("colonies/"):
|
||||
from framework.config import COLONIES_DIR
|
||||
resolved = (COLONIES_DIR / agent_path_str[9:]).resolve()
|
||||
else:
|
||||
resolved = Path(agent_path).expanduser().resolve()
|
||||
|
||||
for root in _get_allowed_agent_roots():
|
||||
if resolved.is_relative_to(root) and resolved != root:
|
||||
return resolved
|
||||
|
||||
@@ -47,6 +47,7 @@ DEFAULT_EVENT_TYPES = [
|
||||
EventType.TRIGGER_REMOVED,
|
||||
EventType.TRIGGER_UPDATED,
|
||||
EventType.DRAFT_GRAPH_UPDATED,
|
||||
EventType.COLONY_CREATION_REQUESTED,
|
||||
]
|
||||
|
||||
# Keepalive interval in seconds
|
||||
|
||||
@@ -115,6 +115,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
"model": "..." (optional),
|
||||
"initial_prompt": "..." (optional — first user message for the queen),
|
||||
"initial_phase": "..." (optional — "independent" for standalone queen),
|
||||
"copy_history_from": "..." (optional — copy events from this session ID),
|
||||
}
|
||||
|
||||
When agent_path is provided, creates a session with a graph in one step
|
||||
@@ -132,6 +133,8 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
# so the full history accumulates in one place across server restarts.
|
||||
queen_resume_from = body.get("queen_resume_from")
|
||||
initial_phase = body.get("initial_phase")
|
||||
# When set, copy conversation history from this session to the new one (DM → colony transition)
|
||||
copy_history_from = body.get("copy_history_from")
|
||||
|
||||
if agent_path:
|
||||
try:
|
||||
@@ -150,6 +153,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
initial_prompt=initial_prompt,
|
||||
queen_resume_from=queen_resume_from,
|
||||
initial_phase=initial_phase,
|
||||
copy_history_from=copy_history_from,
|
||||
)
|
||||
else:
|
||||
# Queen-only session
|
||||
@@ -159,6 +163,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
initial_prompt=initial_prompt,
|
||||
queen_resume_from=queen_resume_from,
|
||||
initial_phase=initial_phase,
|
||||
copy_history_from=copy_history_from,
|
||||
)
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
@@ -308,6 +313,26 @@ async def handle_load_graph(request: web.Request) -> web.Response:
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=409)
|
||||
except FileNotFoundError:
|
||||
# Agent folder exists but no agent.json yet (DM → colony transition)
|
||||
# Set the agent_path on the session so queen knows where to build
|
||||
session = manager.get_session(session_id)
|
||||
if session is not None:
|
||||
from pathlib import Path
|
||||
session.worker_path = Path(agent_path)
|
||||
# Update meta.json with agent_path for cold-restore
|
||||
from framework.server.session_manager import _queen_session_dir
|
||||
storage_session_id = session.queen_resume_from or session.id
|
||||
meta_path = _queen_session_dir(storage_session_id, session.queen_name) / "meta.json"
|
||||
try:
|
||||
existing_meta = {}
|
||||
if meta_path.exists():
|
||||
existing_meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
existing_meta["agent_path"] = str(agent_path)
|
||||
existing_meta["phase"] = "building"
|
||||
meta_path.write_text(json.dumps(existing_meta), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return web.json_response(_session_to_live_dict(session))
|
||||
return web.json_response({"error": f"Agent not found: {agent_path}"}, status=404)
|
||||
except Exception as e:
|
||||
resp = _credential_error_response(e, agent_path)
|
||||
|
||||
@@ -89,6 +89,8 @@ class Session:
|
||||
queen_name: str = "default"
|
||||
# Colony name: set when a worker is loaded from a colony
|
||||
colony_name: str | None = None
|
||||
# DM mode: True for queen-only sessions (not in a colony context)
|
||||
is_dm_mode: bool = False
|
||||
|
||||
|
||||
class SessionManager:
|
||||
@@ -182,6 +184,7 @@ class SessionManager:
|
||||
queen_resume_from: str | None = None,
|
||||
queen_name: str | None = None,
|
||||
initial_phase: str | None = None,
|
||||
copy_history_from: str | None = None,
|
||||
) -> Session:
|
||||
"""Create a new session with a queen but no worker.
|
||||
|
||||
@@ -191,21 +194,30 @@ class SessionManager:
|
||||
|
||||
When ``queen_name`` is set the session is pre-bound to that queen
|
||||
identity, skipping LLM auto-selection in the identity hook.
|
||||
|
||||
When ``copy_history_from`` is set, conversation history is copied from
|
||||
that session to the new one (used for DM → colony transition).
|
||||
"""
|
||||
# Reuse the original session ID when cold-restoring
|
||||
resolved_session_id = queen_resume_from or session_id
|
||||
session = await self._create_session_core(session_id=resolved_session_id, model=model)
|
||||
session.queen_resume_from = queen_resume_from
|
||||
session.is_dm_mode = True # Queen-only session, not in a colony context
|
||||
if queen_name:
|
||||
session.queen_name = queen_name
|
||||
|
||||
# Copy conversation history if requested (DM → colony transition)
|
||||
if copy_history_from:
|
||||
await self._copy_session_history(copy_history_from, session.id)
|
||||
|
||||
# Start queen immediately (queen-only, no worker tools yet)
|
||||
await self._start_queen(session, worker_identity=None, initial_prompt=initial_prompt, initial_phase=initial_phase)
|
||||
|
||||
logger.info(
|
||||
"Session '%s' created (queen-only, resume_from=%s)",
|
||||
"Session '%s' created (queen-only, resume_from=%s, copy_from=%s)",
|
||||
session.id,
|
||||
queen_resume_from,
|
||||
copy_history_from,
|
||||
)
|
||||
return session
|
||||
|
||||
@@ -219,12 +231,16 @@ class SessionManager:
|
||||
queen_resume_from: str | None = None,
|
||||
queen_name: str | None = None,
|
||||
initial_phase: str | None = None,
|
||||
copy_history_from: str | None = None,
|
||||
) -> Session:
|
||||
"""Create a session and load a worker in one step.
|
||||
|
||||
When ``queen_resume_from`` is set the session reuses the original session
|
||||
ID so the frontend sees a single continuous session. The queen writes
|
||||
conversation messages to that existing directory, preserving full history.
|
||||
|
||||
When ``copy_history_from`` is set, conversation history is copied from
|
||||
that session to the new one (used for DM → colony transition).
|
||||
"""
|
||||
from framework.tools.queen_lifecycle_tools import build_worker_profile
|
||||
|
||||
@@ -267,6 +283,11 @@ class SessionManager:
|
||||
session.queen_name = queen_name
|
||||
elif _resume_queen_id:
|
||||
session.queen_name = _resume_queen_id
|
||||
|
||||
# Copy conversation history if requested (DM → colony transition)
|
||||
if copy_history_from:
|
||||
await self._copy_session_history(copy_history_from, session.id)
|
||||
|
||||
try:
|
||||
# Load the graph FIRST (before queen) so queen gets full tools
|
||||
await self._load_worker_core(
|
||||
@@ -927,6 +948,10 @@ class SessionManager:
|
||||
if session.phase_state:
|
||||
session.phase_state.agent_path = _agent_path
|
||||
await session.phase_state.switch_to_building(source="auto")
|
||||
# Emit flowchart so frontend can display the design
|
||||
await self._emit_flowchart_on_restore(session, _agent_path)
|
||||
# Notify queen about the transition from DM mode
|
||||
await self._notify_queen_building_resumed(session, _agent_path)
|
||||
logger.info("Cold restore: resumed BUILDING phase for %s", _agent_path)
|
||||
elif _phase == "planning":
|
||||
if session.phase_state:
|
||||
@@ -968,6 +993,23 @@ class SessionManager:
|
||||
|
||||
await node.inject_event(f"[SYSTEM] Graph loaded.{profile}{trigger_lines}")
|
||||
|
||||
async def _notify_queen_building_resumed(self, session: Session, agent_path: str | Path) -> None:
|
||||
"""Notify queen that she's resuming in BUILDING phase after DM mode transition."""
|
||||
executor = session.queen_executor
|
||||
if executor is None:
|
||||
return
|
||||
node = executor.node_registry.get("queen")
|
||||
if node is None or not hasattr(node, "inject_event"):
|
||||
return
|
||||
|
||||
agent_name = Path(agent_path).name.replace("_", " ").title()
|
||||
await node.inject_event(
|
||||
f"[SYSTEM] Resuming BUILDING phase for '{agent_name}'. "
|
||||
f"The design from our DM conversation has been saved to {agent_path}/flowchart.json. "
|
||||
f"You can read this file to review the agreed-upon design. "
|
||||
f"Continue building the agent by creating the agent.json file."
|
||||
)
|
||||
|
||||
async def _emit_graph_loaded(self, session: Session) -> None:
|
||||
"""Publish a WORKER_GRAPH_LOADED event so the frontend can update."""
|
||||
from framework.host.event_bus import AgentEvent, EventType
|
||||
@@ -1087,6 +1129,39 @@ class SessionManager:
|
||||
session.queen_executor,
|
||||
)
|
||||
|
||||
async def _copy_session_history(self, from_session_id: str, to_session_id: str) -> None:
|
||||
"""Copy conversation history from one session to another (DM → colony transition).
|
||||
|
||||
Copies events.jsonl from the source session directory to the target session directory.
|
||||
This preserves conversation history without sharing the same session.
|
||||
"""
|
||||
from_dir = _find_queen_session_dir(from_session_id)
|
||||
to_dir = _find_queen_session_dir(to_session_id)
|
||||
|
||||
if not from_dir.exists():
|
||||
logger.warning("Cannot copy history: source session '%s' not found", from_session_id)
|
||||
return
|
||||
|
||||
# Ensure target directory exists
|
||||
to_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy events.jsonl if it exists
|
||||
source_events = from_dir / "events.jsonl"
|
||||
if source_events.exists():
|
||||
try:
|
||||
import shutil
|
||||
target_events = to_dir / "events.jsonl"
|
||||
shutil.copy2(source_events, target_events)
|
||||
logger.info(
|
||||
"Copied conversation history from '%s' to '%s'",
|
||||
from_session_id,
|
||||
to_session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to copy events.jsonl: %s", e)
|
||||
else:
|
||||
logger.debug("No events.jsonl to copy from '%s'", from_session_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookups
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -142,6 +142,9 @@ class QueenPhaseState:
|
||||
# Global memory directory.
|
||||
global_memory_dir: Path | None = None
|
||||
|
||||
# DM mode: track if we've already triggered colony navigation (prevents duplicate events)
|
||||
_colony_navigated: bool = False
|
||||
|
||||
def get_current_tools(self) -> list:
|
||||
"""Return tools for the current phase."""
|
||||
if self.phase == "independent":
|
||||
@@ -1890,11 +1893,14 @@ def register_queen_lifecycle_tools(
|
||||
This tool should ONLY be called after the user has explicitly approved
|
||||
the draft graph design via ask_user. It creates the agent directory and
|
||||
transitions to BUILDING phase. The queen then writes agent.json directly.
|
||||
|
||||
In DM mode (queen-only session), this emits a COLONY_CREATION_REQUESTED
|
||||
event instead of building, signaling the frontend to navigate to a colony.
|
||||
"""
|
||||
if phase_state is None:
|
||||
return json.dumps({"error": "Phase state not available."})
|
||||
|
||||
if phase_state.phase != "planning":
|
||||
if phase_state.phase not in ("planning", "independent"):
|
||||
return json.dumps(
|
||||
{"error": f"Cannot confirm_and_build: currently in {phase_state.phase} phase."}
|
||||
)
|
||||
@@ -1909,6 +1915,104 @@ def register_queen_lifecycle_tools(
|
||||
}
|
||||
)
|
||||
|
||||
# Check if we're in DM mode (queen-only session, not in a colony)
|
||||
is_dm_mode = getattr(session, "is_dm_mode", False)
|
||||
_agent_name = (
|
||||
agent_name
|
||||
or phase_state.draft_graph.get("agent_name", "").strip()
|
||||
)
|
||||
|
||||
# Preserve original draft for flowchart display during runtime,
|
||||
# then dissolve planning-only nodes (decision + browser/GCU) into
|
||||
# runtime-compatible structures.
|
||||
import copy as _copy
|
||||
|
||||
original_nodes = phase_state.draft_graph.get("nodes", [])
|
||||
# Compute dissolution first, then assign all three atomically so that
|
||||
# a failure in _dissolve_planning_nodes doesn't leave partial state.
|
||||
original_copy = _copy.deepcopy(phase_state.draft_graph)
|
||||
converted, fmap = _dissolve_planning_nodes(phase_state.draft_graph)
|
||||
phase_state.original_draft_graph = original_copy
|
||||
phase_state.draft_graph = converted
|
||||
phase_state.flowchart_map = fmap
|
||||
|
||||
# Create agent folder early so flowchart and agent_path are available
|
||||
# throughout the entire BUILDING phase (or for colony navigation in DM mode)
|
||||
_agent_folder = None
|
||||
if _agent_name:
|
||||
from framework.config import COLONIES_DIR
|
||||
|
||||
_agent_folder = COLONIES_DIR / _agent_name
|
||||
_agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
_save_flowchart_file(_agent_folder, original_copy, fmap)
|
||||
phase_state.agent_path = str(_agent_folder)
|
||||
_update_meta_json(
|
||||
session_manager,
|
||||
manager_session_id,
|
||||
{
|
||||
"agent_path": str(_agent_folder),
|
||||
"agent_name": _agent_name.replace("_", " ").title(),
|
||||
},
|
||||
)
|
||||
|
||||
if is_dm_mode:
|
||||
# In DM mode: emit event for frontend to navigate to colony
|
||||
# The agent folder is already created above with the draft
|
||||
|
||||
# Guard against duplicate calls - check if we already emitted the event in this session
|
||||
_colony_navigated = getattr(phase_state, "_colony_navigated", False)
|
||||
if _colony_navigated:
|
||||
# Already navigated in this session - don't emit duplicate event
|
||||
existing_path = getattr(phase_state, "agent_path", None)
|
||||
logger.info(
|
||||
"Colony navigation already triggered for '%s', skipping duplicate",
|
||||
existing_path,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "colony_creation_requested",
|
||||
"agent_name": _agent_name or getattr(phase_state, "draft_graph", {}).get("agent_name", "colony"),
|
||||
"agent_path": existing_path or str(_agent_folder),
|
||||
"message": "Colony navigation already in progress.",
|
||||
}
|
||||
)
|
||||
|
||||
# Mark that we're navigating so subsequent calls don't re-emit
|
||||
phase_state._colony_navigated = True
|
||||
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus is not None:
|
||||
logger.info(
|
||||
"Emitting COLONY_CREATION_REQUESTED for agent '%s' at '%s'",
|
||||
_agent_name,
|
||||
_agent_folder,
|
||||
)
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.COLONY_CREATION_REQUESTED,
|
||||
stream_id="queen",
|
||||
data={
|
||||
"agent_name": _agent_name,
|
||||
"agent_path": str(_agent_folder) if _agent_folder else None,
|
||||
"queen_id": getattr(phase_state, "queen_id", None),
|
||||
},
|
||||
)
|
||||
)
|
||||
# Give the event a moment to be serialized and sent
|
||||
await asyncio.sleep(0.1)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "colony_creation_requested",
|
||||
"agent_name": _agent_name,
|
||||
"agent_path": str(_agent_folder) if _agent_folder else None,
|
||||
"message": (
|
||||
f"Design confirmed for '{_agent_name}'. "
|
||||
"A new colony is being created for this agent. "
|
||||
"The user will be navigated there shortly to complete the build."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
phase_state.build_confirmed = True
|
||||
|
||||
# Preserve original draft for flowchart display during runtime,
|
||||
@@ -1927,10 +2031,6 @@ def register_queen_lifecycle_tools(
|
||||
|
||||
# Create agent folder early so flowchart and agent_path are available
|
||||
# throughout the entire BUILDING phase.
|
||||
_agent_name = (
|
||||
agent_name
|
||||
or phase_state.draft_graph.get("agent_name", "").strip()
|
||||
)
|
||||
if _agent_name:
|
||||
from framework.config import COLONIES_DIR
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const sessionsApi = {
|
||||
// --- Session lifecycle ---
|
||||
|
||||
/** Create a session. If agentPath is provided, loads a graph in one step. */
|
||||
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string, queenResumeFrom?: string, initialPhase?: string) =>
|
||||
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string, queenResumeFrom?: string, initialPhase?: string, copyHistoryFrom?: string) =>
|
||||
api.post<LiveSession>("/sessions", {
|
||||
agent_path: agentPath,
|
||||
agent_id: agentId,
|
||||
@@ -19,6 +19,7 @@ export const sessionsApi = {
|
||||
initial_prompt: initialPrompt,
|
||||
queen_resume_from: queenResumeFrom || undefined,
|
||||
initial_phase: initialPhase || undefined,
|
||||
copy_history_from: copyHistoryFrom || undefined,
|
||||
}),
|
||||
|
||||
/** List all active sessions. */
|
||||
|
||||
@@ -311,7 +311,8 @@ export type EventTypeName =
|
||||
| "trigger_fired"
|
||||
| "trigger_removed"
|
||||
| "trigger_updated"
|
||||
| "queen_identity_selected";
|
||||
| "queen_identity_selected"
|
||||
| "colony_creation_requested";
|
||||
|
||||
export interface AgentEvent {
|
||||
type: EventTypeName;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
import DraftGraph from "@/components/DraftGraph";
|
||||
@@ -25,7 +25,7 @@ import { cronToLabel } from "@/lib/graphUtils";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useColony } from "@/context/ColonyContext";
|
||||
import { useHeaderActions } from "@/context/HeaderActionsContext";
|
||||
import { agentSlug, getQueenForAgent } from "@/lib/colony-registry";
|
||||
import { agentSlug, getQueenForAgent, slugToDisplayName } from "@/lib/colony-registry";
|
||||
import BrowserStatusBadge from "@/components/BrowserStatusBadge";
|
||||
|
||||
const makeId = () => Math.random().toString(36).slice(2, 9);
|
||||
@@ -190,21 +190,38 @@ function defaultAgentState(): AgentState {
|
||||
|
||||
export default function ColonyChat() {
|
||||
const { colonyId } = useParams<{ colonyId: string }>();
|
||||
const { colonies, markVisited } = useColony();
|
||||
const location = useLocation();
|
||||
const { colonies, markVisited, refresh: refreshColonies } = useColony();
|
||||
const { setActions } = useHeaderActions();
|
||||
|
||||
// Get queenResumeFrom from navigation state (set when transitioning from DM mode)
|
||||
const queenResumeFrom = (location.state as { queenResumeFrom?: string } | null)?.queenResumeFrom;
|
||||
|
||||
// Find the colony matching this route
|
||||
const colony = colonies.find((c) => c.id === colonyId);
|
||||
const agentPath = colony?.agentPath ?? "";
|
||||
|
||||
// If colony not found (e.g., newly created from DM mode), derive agent path from URL
|
||||
// colonyId is like "founder-twitter-agent", agent folder is like "founder_twitter_agent"
|
||||
const derivedAgentPath = colonyId
|
||||
? `colonies/${colonyId.replace(/-/g, "_")}`
|
||||
: "";
|
||||
const agentPath = colony?.agentPath ?? derivedAgentPath;
|
||||
const slug = agentPath ? agentSlug(agentPath) : "";
|
||||
const queenInfo = getQueenForAgent(slug);
|
||||
const colonyName = colony?.name ?? colonyId ?? "Colony";
|
||||
const colonyName = colony?.name ?? slugToDisplayName(slug) ?? colonyId ?? "Colony";
|
||||
|
||||
// Mark colony as visited when navigating to it
|
||||
useEffect(() => {
|
||||
if (colonyId) markVisited(colonyId);
|
||||
}, [colonyId, markVisited]);
|
||||
|
||||
// Refresh colonies if colony not found (e.g., newly created from DM mode)
|
||||
useEffect(() => {
|
||||
if (colonyId && !colony) {
|
||||
refreshColonies();
|
||||
}
|
||||
}, [colonyId, colony, refreshColonies]);
|
||||
|
||||
// ── Core state ───────────────────────────────────────────────────────────
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -422,23 +439,41 @@ export default function ColonyChat() {
|
||||
let restoredOriginalDraft: DraftGraphData | null = null;
|
||||
|
||||
if (!liveSession) {
|
||||
// Pre-fetch messages from cold session
|
||||
// Pre-fetch messages from DM session (if transitioning from DM) or cold session
|
||||
let preRestoredMsgs: ChatMessage[] = [];
|
||||
if (coldRestoreId) {
|
||||
// Prioritize queenResumeFrom (DM session) over coldRestoreId - DM conversation is the source of truth
|
||||
const historySessionId = queenResumeFrom ?? coldRestoreId;
|
||||
if (historySessionId) {
|
||||
const displayName = formatAgentDisplayName(agentPath);
|
||||
const restored = await restoreSessionMessages(coldRestoreId, agentPath, displayName);
|
||||
const restored = await restoreSessionMessages(historySessionId, agentPath, displayName);
|
||||
preRestoredMsgs = restored.messages;
|
||||
restoredPhase = restored.restoredPhase;
|
||||
restoredFlowchartMap = restored.flowchartMap;
|
||||
restoredOriginalDraft = restored.originalDraft;
|
||||
}
|
||||
|
||||
if (coldRestoreId || preRestoredMsgs.length > 0) {
|
||||
if (historySessionId || preRestoredMsgs.length > 0) {
|
||||
suppressIntroRef.current = true;
|
||||
}
|
||||
|
||||
// Create new session (pass coldRestoreId for resume)
|
||||
liveSession = await sessionsApi.create(agentPath, undefined, undefined, undefined, coldRestoreId ?? undefined);
|
||||
// Create new session
|
||||
// - For DM transition: create queen-only session first (no worker yet, just flowchart)
|
||||
// - For cold resume: try to load worker if agent is complete
|
||||
if (queenResumeFrom) {
|
||||
// DM → colony transition: create queen-only session, queen will build the agent
|
||||
// Don't pass agentPath - the colony only has flowchart.json, no worker yet
|
||||
liveSession = await sessionsApi.create(undefined, undefined, undefined, undefined, undefined, "building", queenResumeFrom);
|
||||
// Then load the graph (worker) separately - this may fail if agent.json doesn't exist yet
|
||||
try {
|
||||
liveSession = await sessionsApi.loadGraph(liveSession.session_id, agentPath);
|
||||
} catch {
|
||||
// Worker not ready yet (no agent.json), queen will build it
|
||||
console.log("Agent not fully built yet, queen will build in BUILDING phase");
|
||||
}
|
||||
} else {
|
||||
// Regular cold resume
|
||||
liveSession = await sessionsApi.create(agentPath, undefined, undefined, undefined, coldRestoreId ?? undefined);
|
||||
}
|
||||
|
||||
if (preRestoredMsgs.length > 0) {
|
||||
preRestoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||
@@ -479,7 +514,7 @@ export default function ColonyChat() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasRestoredContent = isResumedSession || !!coldRestoreId;
|
||||
const hasRestoredContent = isResumedSession || !!coldRestoreId || !!queenResumeFrom;
|
||||
if (!hasRestoredContent) suppressIntroRef.current = false;
|
||||
|
||||
updateState({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Loader2, ArrowRight, Hexagon } from "lucide-react";
|
||||
import ChatPanel, { type ChatMessage, type ImageContent } from "@/components/ChatPanel";
|
||||
import QueenSessionSwitcher from "@/components/QueenSessionSwitcher";
|
||||
import { executionApi } from "@/api/execution";
|
||||
@@ -17,6 +17,7 @@ const makeId = () => Math.random().toString(36).slice(2, 9);
|
||||
|
||||
export default function QueenDM() {
|
||||
const { queenId } = useParams<{ queenId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { queens, queenProfiles, refresh } = useColony();
|
||||
const { setActions } = useHeaderActions();
|
||||
@@ -38,6 +39,7 @@ export default function QueenDM() {
|
||||
{ id: string; prompt: string; options?: string[] }[] | null
|
||||
>(null);
|
||||
const [awaitingInput, setAwaitingInput] = useState(false);
|
||||
const [transitioningToColony, setTransitioningToColony] = useState<string | null>(null);
|
||||
const [, setActiveToolCalls] = useState<Record<string, { name: string; done: boolean }>>({});
|
||||
const [historySessions, setHistorySessions] = useState<HistorySession[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
@@ -218,10 +220,47 @@ export default function QueenDM() {
|
||||
setActiveToolCalls({});
|
||||
break;
|
||||
|
||||
case "execution_completed":
|
||||
case "execution_completed": {
|
||||
setIsTyping(false);
|
||||
setIsStreaming(false);
|
||||
// Flush any remaining LLM text
|
||||
const executionId = event.execution_id;
|
||||
if (executionId) {
|
||||
const prefix = `${executionId}:`;
|
||||
const matchingKeys = Object.keys(queenIterTextRef.current).filter(k => k.startsWith(prefix));
|
||||
|
||||
for (const iterKey of matchingKeys) {
|
||||
const parts = queenIterTextRef.current[iterKey];
|
||||
const sorted = Object.keys(parts)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const content = sorted.map((k) => parts[k]).join("\n");
|
||||
if (content.trim()) {
|
||||
const chatMsg: ChatMessage = {
|
||||
id: `queen-stream-${iterKey}-final`,
|
||||
agent: queenName,
|
||||
agentColor: "",
|
||||
content: content,
|
||||
timestamp: "",
|
||||
type: "agent",
|
||||
thread: "queen-dm",
|
||||
createdAt: Date.now(),
|
||||
role: "queen",
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === chatMsg.id);
|
||||
if (idx >= 0) {
|
||||
return prev.map((m, i) => (i === idx ? chatMsg : m));
|
||||
}
|
||||
return [...prev, chatMsg];
|
||||
});
|
||||
}
|
||||
delete queenIterTextRef.current[iterKey];
|
||||
}
|
||||
}
|
||||
setActiveToolCalls({});
|
||||
break;
|
||||
}
|
||||
|
||||
case "llm_turn_complete":
|
||||
turnCounterRef.current++;
|
||||
@@ -312,84 +351,137 @@ export default function QueenDM() {
|
||||
}
|
||||
|
||||
case "tool_call_started": {
|
||||
const toolName = (event.data?.tool_name as string) || "unknown";
|
||||
const toolUseId = (event.data?.tool_use_id as string) || "";
|
||||
const sid = event.stream_id;
|
||||
const execId = event.execution_id || "exec";
|
||||
|
||||
setActiveToolCalls((prev) => {
|
||||
const newActive = { ...prev, [toolUseId]: { name: toolName, done: false } };
|
||||
const tools = Object.entries(newActive).map(([, t]) => ({ name: t.name, done: t.done }));
|
||||
const allDone = tools.length > 0 && tools.every((t) => t.done);
|
||||
const msgId = `tool-pill-${sid}-${execId}-${turnCounterRef.current}`;
|
||||
const toolMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
agent: queenName,
|
||||
agentColor: "",
|
||||
content: JSON.stringify({ tools, allDone }),
|
||||
timestamp: "",
|
||||
type: "tool_status",
|
||||
role: "queen",
|
||||
thread: "queen-dm",
|
||||
createdAt: Date.now(),
|
||||
nodeId: event.node_id || undefined,
|
||||
executionId: event.execution_id || undefined,
|
||||
};
|
||||
setMessages((prevMsgs) => {
|
||||
const idx = prevMsgs.findIndex((m) => m.id === msgId);
|
||||
if (idx >= 0) {
|
||||
return prevMsgs.map((m, i) => (i === idx ? toolMsg : m));
|
||||
// Flush any pending LLM text before showing tool call
|
||||
// The ref is keyed by "${execution_id}:${iteration}", find all matching entries
|
||||
const executionId = event.execution_id;
|
||||
if (executionId) {
|
||||
const prefix = `${executionId}:`;
|
||||
const matchingKeys = Object.keys(queenIterTextRef.current).filter(k => k.startsWith(prefix));
|
||||
|
||||
for (const iterKey of matchingKeys) {
|
||||
const parts = queenIterTextRef.current[iterKey];
|
||||
const sorted = Object.keys(parts)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const content = sorted.map((k) => parts[k]).join("\n");
|
||||
if (content.trim()) {
|
||||
const chatMsg: ChatMessage = {
|
||||
id: `queen-stream-${iterKey}-final`,
|
||||
agent: queenName,
|
||||
agentColor: "",
|
||||
content: content,
|
||||
timestamp: "",
|
||||
type: "agent",
|
||||
thread: "queen-dm",
|
||||
createdAt: Date.now(),
|
||||
role: "queen",
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === chatMsg.id);
|
||||
if (idx >= 0) {
|
||||
return prev.map((m, i) => (i === idx ? chatMsg : m));
|
||||
}
|
||||
return [...prev, chatMsg];
|
||||
});
|
||||
}
|
||||
return [...prevMsgs, toolMsg];
|
||||
});
|
||||
return newActive;
|
||||
});
|
||||
// Clear the ref after flushing
|
||||
delete queenIterTextRef.current[iterKey];
|
||||
}
|
||||
}
|
||||
setActiveToolCalls((prev) => ({
|
||||
...prev,
|
||||
[event.timestamp || Date.now().toString()]: { name: (event.data?.tool_name as string) || "tool", done: false }
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call_completed": {
|
||||
const toolUseId = (event.data?.tool_use_id as string) || "";
|
||||
const sid = event.stream_id;
|
||||
const execId = event.execution_id || "exec";
|
||||
|
||||
setActiveToolCalls((prev) => {
|
||||
const updated = { ...prev };
|
||||
if (updated[toolUseId]) {
|
||||
updated[toolUseId] = { ...updated[toolUseId], done: true };
|
||||
}
|
||||
const tools = Object.entries(updated).map(([, t]) => ({ name: t.name, done: t.done }));
|
||||
const allDone = tools.length > 0 && tools.every((t) => t.done);
|
||||
const msgId = `tool-pill-${sid}-${execId}-${turnCounterRef.current}`;
|
||||
const toolMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
agent: queenName,
|
||||
agentColor: "",
|
||||
content: JSON.stringify({ tools, allDone }),
|
||||
timestamp: "",
|
||||
type: "tool_status",
|
||||
role: "queen",
|
||||
thread: "queen-dm",
|
||||
createdAt: Date.now(),
|
||||
nodeId: event.node_id || undefined,
|
||||
executionId: event.execution_id || undefined,
|
||||
};
|
||||
setMessages((prevMsgs) => {
|
||||
const idx = prevMsgs.findIndex((m) => m.id === msgId);
|
||||
if (idx >= 0) {
|
||||
return prevMsgs.map((m, i) => (i === idx ? toolMsg : m));
|
||||
// Suppress ask_user/ask_user_multiple tool results from appearing in chat
|
||||
// They should only show as question modals via client_input_requested
|
||||
const toolName = (event.data?.tool_name as string) || "";
|
||||
if (toolName === "ask_user" || toolName === "ask_user_multiple") {
|
||||
// Don't add to chat - the question modal will handle this
|
||||
break;
|
||||
}
|
||||
// For other tools, show the result
|
||||
const result = event.data?.result as string | undefined;
|
||||
if (result) {
|
||||
try {
|
||||
// Try to parse as JSON to see if it's a structured result
|
||||
const parsed = JSON.parse(result);
|
||||
// If it's a simple success message, show it
|
||||
if (parsed.message && typeof parsed.message === "string") {
|
||||
const chatMsg: ChatMessage = {
|
||||
id: `tool-result-${event.timestamp || Date.now()}`,
|
||||
agent: queenName,
|
||||
agentColor: "",
|
||||
content: parsed.message,
|
||||
timestamp: "",
|
||||
type: "agent",
|
||||
thread: "queen-dm",
|
||||
createdAt: Date.now(),
|
||||
role: "queen",
|
||||
};
|
||||
setMessages((prev) => [...prev, chatMsg]);
|
||||
}
|
||||
return [...prevMsgs, toolMsg];
|
||||
});
|
||||
return updated;
|
||||
} catch {
|
||||
// Not JSON, show raw result (truncated)
|
||||
const chatMsg: ChatMessage = {
|
||||
id: `tool-result-${event.timestamp || Date.now()}`,
|
||||
agent: queenName,
|
||||
agentColor: "",
|
||||
content: result.slice(0, 500),
|
||||
timestamp: "",
|
||||
type: "agent",
|
||||
thread: "queen-dm",
|
||||
createdAt: Date.now(),
|
||||
role: "queen",
|
||||
};
|
||||
setMessages((prev) => [...prev, chatMsg]);
|
||||
}
|
||||
}
|
||||
setActiveToolCalls((prev) => {
|
||||
const key = Object.keys(prev).find(k => !prev[k].done);
|
||||
if (key) {
|
||||
return { ...prev, [key]: { ...prev[key], done: true } };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "colony_creation_requested": {
|
||||
// Queen in DM mode requested to create a colony for the agent
|
||||
const agentPath = event.data?.agent_path as string | undefined;
|
||||
const agentName = event.data?.agent_name as string | undefined;
|
||||
if (agentPath) {
|
||||
// Extract colony ID from agent path (e.g., "founder_twitter_agent" → "founder-twitter-agent")
|
||||
const slug = agentPath.replace(/\/$/, "").split("/").pop() || "";
|
||||
const colonyId = slug.replace(/_/g, "-");
|
||||
|
||||
// Show transition state
|
||||
setTransitioningToColony(agentName || colonyId);
|
||||
setIsTyping(false);
|
||||
|
||||
// Pass the DM session ID so colony can resume from it
|
||||
// This preserves conversation history when transitioning to colony
|
||||
const dmSessionId = sessionId;
|
||||
|
||||
// Delay navigation to let user see the queen's message
|
||||
setTimeout(() => {
|
||||
navigate(`/colony/${colonyId}`, {
|
||||
state: { queenResumeFrom: dmSessionId }
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[queenName],
|
||||
[queenName, navigate, sessionId],
|
||||
);
|
||||
|
||||
const sseSessions = useMemo((): Record<string, string> => {
|
||||
@@ -480,6 +572,26 @@ export default function QueenDM() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transitioningToColony && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<Hexagon className="w-8 h-8 text-primary animate-pulse" />
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground" />
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Design approved! Moving to colony...
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{transitioningToColony.replace(/_/g, " ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
onSend={handleSend}
|
||||
@@ -487,7 +599,7 @@ export default function QueenDM() {
|
||||
activeThread="queen-dm"
|
||||
isWaiting={isTyping && !isStreaming}
|
||||
isBusy={isTyping}
|
||||
disabled={loading || !queenReady}
|
||||
disabled={loading || !queenReady || !!transitioningToColony}
|
||||
queenPhase={queenPhase}
|
||||
pendingQuestion={awaitingInput ? pendingQuestion : null}
|
||||
pendingOptions={awaitingInput ? pendingOptions : null}
|
||||
|
||||
Reference in New Issue
Block a user