Compare commits

...

2 Commits

Author SHA1 Message Date
Richard Tang f3fd2ad650 Merge branch 'feature/hive-experimental-comp-pipeline' into feat/open-hive-colony 2026-04-08 19:15:32 -07:00
bryan 626f9479c8 [wip] moving to colony 2026-04-08 18:51:32 -07:00
13 changed files with 525 additions and 100 deletions
+22
View File
@@ -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(
+45 -9
View File
@@ -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)
+3
View File
@@ -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:
+6 -2
View File
@@ -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:
+11 -1
View File
@@ -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
+1
View File
@@ -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
+25
View File
@@ -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)
+76 -1
View File
@@ -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
# ------------------------------------------------------------------
+105 -5
View File
@@ -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
+2 -1
View File
@@ -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. */
+2 -1
View File
@@ -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;
+47 -12
View File
@@ -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({
+180 -68
View File
@@ -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}