fix: ensure flowchart existence
This commit is contained in:
@@ -307,6 +307,28 @@ Present a short **Framework Fit Assessment**:
|
||||
- **Gaps/Deal-breakers**: Only list genuinely missing capabilities after checking \
|
||||
both list_agent_tools() and built-in features like GCU
|
||||
|
||||
### Credential Check (MANDATORY)
|
||||
|
||||
The summary from list_agent_tools() includes `credentials_required` and \
|
||||
`credentials_available` per provider. **Before designing the graph**, check \
|
||||
which providers the design will need and whether credentials are available.
|
||||
|
||||
For each provider whose tools you plan to use and where \
|
||||
`credentials_available` is false:
|
||||
- Tell the user which credential is missing and what it's needed for
|
||||
- Ask if they have access to set it up (e.g., API key, OAuth, service account)
|
||||
- If they don't have access, adjust the design to work without that provider \
|
||||
or suggest alternatives
|
||||
|
||||
**Do NOT proceed to the design step with tools that require unavailable \
|
||||
credentials without the user acknowledging it.** Finding out at runtime that \
|
||||
credentials are missing wastes everyone's time. Surface this early.
|
||||
|
||||
Example:
|
||||
> "The design needs Google Sheets tools, but the `google` credential isn't \
|
||||
configured yet. Do you have a Google service account or OAuth credentials \
|
||||
you can set up? If not, I can use CSV file output instead."
|
||||
|
||||
## 3: Design Graph and Create Draft
|
||||
|
||||
Act like an experienced AI solution architect. Design the agent architecture:
|
||||
|
||||
@@ -250,21 +250,45 @@ async def handle_draft_graph(request: web.Request) -> web.Response:
|
||||
async def handle_flowchart_map(request: web.Request) -> web.Response:
|
||||
"""Return the flowchart→runtime node mapping and the original (pre-dissolution) draft.
|
||||
|
||||
Available after confirm_and_build() dissolves decision nodes. Used by the
|
||||
frontend to overlay runtime execution status onto the user-facing flowchart.
|
||||
Available after confirm_and_build() dissolves decision nodes, or loaded
|
||||
from the agent's flowchart.json file, or synthesized from the runtime graph.
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
phase_state = getattr(session, "phase_state", None)
|
||||
if phase_state is None:
|
||||
return web.json_response({"map": None, "original_draft": None})
|
||||
|
||||
return web.json_response({
|
||||
"map": phase_state.flowchart_map,
|
||||
"original_draft": phase_state.original_draft_graph,
|
||||
})
|
||||
# Fast path: already in memory
|
||||
if phase_state is not None and phase_state.original_draft_graph is not None:
|
||||
return web.json_response({
|
||||
"map": phase_state.flowchart_map,
|
||||
"original_draft": phase_state.original_draft_graph,
|
||||
})
|
||||
|
||||
# Try loading from flowchart.json in the agent folder
|
||||
worker_path = getattr(session, "worker_path", None)
|
||||
if worker_path is not None:
|
||||
from pathlib import Path
|
||||
|
||||
target = Path(worker_path) / "flowchart.json"
|
||||
if target.is_file():
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
original_draft = data.get("original_draft")
|
||||
fmap = data.get("flowchart_map")
|
||||
# Cache in phase_state for future requests
|
||||
if phase_state is not None and original_draft:
|
||||
phase_state.original_draft_graph = original_draft
|
||||
phase_state.flowchart_map = fmap
|
||||
return web.json_response({
|
||||
"map": fmap,
|
||||
"original_draft": original_draft,
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Failed to read flowchart.json from %s", worker_path)
|
||||
|
||||
return web.json_response({"map": None, "original_draft": None})
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
|
||||
@@ -95,6 +95,9 @@ class QueenPhaseState:
|
||||
# Built during decision-node dissolution at confirm_and_build().
|
||||
flowchart_map: dict[str, list[str]] | None = None
|
||||
|
||||
# Agent path — set after scaffolding so the frontend can query credentials
|
||||
agent_path: str | None = None
|
||||
|
||||
# Phase-specific prompts (set by session_manager after construction)
|
||||
prompt_planning: str = ""
|
||||
prompt_building: str = ""
|
||||
@@ -130,11 +133,14 @@ class QueenPhaseState:
|
||||
async def _emit_phase_event(self) -> None:
|
||||
"""Publish a QUEEN_PHASE_CHANGED event so the frontend updates the tag."""
|
||||
if self.event_bus is not None:
|
||||
data: dict = {"phase": self.phase}
|
||||
if self.agent_path:
|
||||
data["agent_path"] = self.agent_path
|
||||
await self.event_bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.QUEEN_PHASE_CHANGED,
|
||||
stream_id="queen",
|
||||
data={"phase": self.phase},
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -652,6 +658,144 @@ def register_queen_lifecycle_tools(
|
||||
registry.register("replan_agent", _replan_tool, lambda inputs: replan_agent())
|
||||
tools_registered += 1
|
||||
|
||||
# --- Flowchart file persistence -------------------------------------------
|
||||
# The flowchart is saved as flowchart.json in the agent's folder so it
|
||||
# survives restarts and is available when loading any agent.
|
||||
|
||||
FLOWCHART_FILENAME = "flowchart.json"
|
||||
|
||||
def _save_flowchart_file(
|
||||
agent_path: Path | str | None,
|
||||
original_draft: dict,
|
||||
flowchart_map: dict[str, list[str]] | None,
|
||||
) -> None:
|
||||
"""Persist the flowchart to the agent's folder."""
|
||||
if agent_path is None:
|
||||
return
|
||||
p = Path(agent_path)
|
||||
if not p.is_dir():
|
||||
return
|
||||
try:
|
||||
target = p / FLOWCHART_FILENAME
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
{"original_draft": original_draft, "flowchart_map": flowchart_map},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.debug("Flowchart saved to %s", target)
|
||||
except Exception:
|
||||
logger.warning("Failed to save flowchart to %s", p, exc_info=True)
|
||||
|
||||
def _load_flowchart_file(
|
||||
agent_path: Path | str | None,
|
||||
) -> tuple[dict | None, dict[str, list[str]] | None]:
|
||||
"""Load flowchart from the agent's folder. Returns (original_draft, flowchart_map)."""
|
||||
if agent_path is None:
|
||||
return None, None
|
||||
target = Path(agent_path) / FLOWCHART_FILENAME
|
||||
if not target.is_file():
|
||||
return None, None
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
return data.get("original_draft"), data.get("flowchart_map")
|
||||
except Exception:
|
||||
logger.warning("Failed to load flowchart from %s", target, exc_info=True)
|
||||
return None, None
|
||||
|
||||
def _synthesize_draft_from_runtime(
|
||||
runtime_nodes: list,
|
||||
runtime_edges: list,
|
||||
agent_name: str = "",
|
||||
goal_name: str = "",
|
||||
) -> tuple[dict, dict[str, list[str]]]:
|
||||
"""Generate a flowchart draft from a loaded runtime graph.
|
||||
|
||||
Used for agents that were never planned through the draft workflow
|
||||
(e.g., hand-coded or loaded from "my agents"). Produces a valid
|
||||
DraftGraph structure with auto-classified flowchart types.
|
||||
"""
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
node_ids = {n.id for n in runtime_nodes}
|
||||
|
||||
# Build edge dicts first (needed for classification)
|
||||
for i, re in enumerate(runtime_edges):
|
||||
edges.append({
|
||||
"id": f"edge-{i}",
|
||||
"source": re.source,
|
||||
"target": re.target,
|
||||
"condition": str(re.condition.value) if hasattr(re.condition, "value") else str(re.condition),
|
||||
"description": getattr(re, "description", "") or "",
|
||||
"label": "",
|
||||
})
|
||||
|
||||
# Terminal detection
|
||||
sources = {e["source"] for e in edges}
|
||||
terminal_ids = node_ids - sources
|
||||
if not terminal_ids and runtime_nodes:
|
||||
terminal_ids = {runtime_nodes[-1].id}
|
||||
|
||||
# Build node dicts with classification
|
||||
total = len(runtime_nodes)
|
||||
for i, rn in enumerate(runtime_nodes):
|
||||
node: dict = {
|
||||
"id": rn.id,
|
||||
"name": rn.name,
|
||||
"description": rn.description or "",
|
||||
"node_type": getattr(rn, "node_type", "event_loop") or "event_loop",
|
||||
"tools": list(rn.tools) if rn.tools else [],
|
||||
"input_keys": list(rn.input_keys) if rn.input_keys else [],
|
||||
"output_keys": list(rn.output_keys) if rn.output_keys else [],
|
||||
"success_criteria": getattr(rn, "success_criteria", "") or "",
|
||||
"sub_agents": list(rn.sub_agents) if getattr(rn, "sub_agents", None) else [],
|
||||
}
|
||||
fc_type = _classify_flowchart_node(node, i, total, edges, terminal_ids)
|
||||
fc_meta = _FLOWCHART_TYPES[fc_type]
|
||||
node["flowchart_type"] = fc_type
|
||||
node["flowchart_shape"] = fc_meta["shape"]
|
||||
node["flowchart_color"] = fc_meta["color"]
|
||||
nodes.append(node)
|
||||
|
||||
# Add visual edges from parent nodes to their sub_agents.
|
||||
# Sub-agents are connected via the sub_agents field, not via EdgeSpec,
|
||||
# so they'd appear as disconnected islands without this.
|
||||
edge_counter = len(edges)
|
||||
for node in nodes:
|
||||
for sa_id in node.get("sub_agents") or []:
|
||||
if sa_id in node_ids:
|
||||
edges.append({
|
||||
"id": f"edge-subagent-{edge_counter}",
|
||||
"source": node["id"],
|
||||
"target": sa_id,
|
||||
"condition": "always",
|
||||
"description": "sub-agent delegation",
|
||||
"label": "delegate",
|
||||
})
|
||||
edge_counter += 1
|
||||
|
||||
# 1:1 flowchart map (no dissolution happened)
|
||||
fmap = {n["id"]: [n["id"]] for n in nodes}
|
||||
|
||||
draft = {
|
||||
"agent_name": agent_name,
|
||||
"goal": goal_name,
|
||||
"description": "",
|
||||
"success_criteria": [],
|
||||
"constraints": [],
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"entry_node": nodes[0]["id"] if nodes else "",
|
||||
"terminal_nodes": sorted(terminal_ids),
|
||||
"flowchart_legend": {
|
||||
fc_type: {"shape": meta["shape"], "color": meta["color"]}
|
||||
for fc_type, meta in _FLOWCHART_TYPES.items()
|
||||
},
|
||||
}
|
||||
|
||||
return draft, fmap
|
||||
|
||||
# --- save_agent_draft (Planning phase — declarative graph preview) ---------
|
||||
# Creates a lightweight draft graph with nodes, edges, and business metadata.
|
||||
# Loose validation: only requires names and descriptions. Emits an event
|
||||
@@ -1151,6 +1295,20 @@ def register_queen_lifecycle_tools(
|
||||
phase_state.draft_graph = converted
|
||||
phase_state.flowchart_map = fmap
|
||||
# Do NOT reset build_confirmed — we're already building.
|
||||
# Persist to agent folder
|
||||
save_path = getattr(session, "worker_path", None)
|
||||
if save_path is None:
|
||||
# Worker not loaded yet — resolve from draft name
|
||||
draft_name = draft.get("agent_name", "")
|
||||
if draft_name:
|
||||
candidate = Path("exports") / draft_name
|
||||
if candidate.is_dir():
|
||||
save_path = candidate
|
||||
_save_flowchart_file(
|
||||
save_path,
|
||||
phase_state.original_draft_graph,
|
||||
fmap,
|
||||
)
|
||||
else:
|
||||
# During planning: store raw draft, await user confirmation.
|
||||
phase_state.draft_graph = draft
|
||||
@@ -1426,6 +1584,9 @@ def register_queen_lifecycle_tools(
|
||||
phase_state.draft_graph = converted
|
||||
phase_state.flowchart_map = fmap
|
||||
|
||||
# Note: flowchart file is persisted later, in initialize_and_build_agent
|
||||
# (after the agent folder is scaffolded) or in load_built_agent.
|
||||
|
||||
dissolved_count = len(original_nodes) - len(converted.get("nodes", []))
|
||||
decision_count = sum(
|
||||
1 for n in original_nodes if n.get("flowchart_type") == "decision"
|
||||
@@ -1547,6 +1708,8 @@ def register_queen_lifecycle_tools(
|
||||
agent_name,
|
||||
)
|
||||
if phase_state is not None:
|
||||
if fallback_path:
|
||||
phase_state.agent_path = str(fallback_path)
|
||||
await phase_state.switch_to_building(source="tool")
|
||||
if phase_state.inject_notification:
|
||||
await phase_state.inject_notification(
|
||||
@@ -1589,9 +1752,18 @@ def register_queen_lifecycle_tools(
|
||||
parsed = json.loads(result_str)
|
||||
if parsed.get("success", True):
|
||||
if phase_state is not None:
|
||||
# Set agent_path so the frontend can query credentials
|
||||
phase_state.agent_path = str(Path("exports") / agent_name)
|
||||
await phase_state.switch_to_building(source="tool")
|
||||
# Reset draft state after successful scaffolding
|
||||
phase_state.build_confirmed = False
|
||||
# Persist flowchart now that the agent folder exists
|
||||
if phase_state.original_draft_graph and phase_state.flowchart_map:
|
||||
_save_flowchart_file(
|
||||
Path("exports") / agent_name,
|
||||
phase_state.original_draft_graph,
|
||||
phase_state.flowchart_map,
|
||||
)
|
||||
# Inject a continuation message so the queen starts
|
||||
# building immediately instead of blocking for user input.
|
||||
draft_hint = ""
|
||||
@@ -2679,31 +2851,56 @@ def register_queen_lifecycle_tools(
|
||||
}
|
||||
)
|
||||
|
||||
# Emit flowchart map to frontend if draft exists (so overlay
|
||||
# renders immediately after load without waiting for a poll).
|
||||
if (
|
||||
phase_state is not None
|
||||
and phase_state.original_draft_graph is not None
|
||||
and phase_state.flowchart_map is not None
|
||||
):
|
||||
bus = phase_state.event_bus
|
||||
if bus is not None:
|
||||
try:
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.FLOWCHART_MAP_UPDATED,
|
||||
stream_id="queen",
|
||||
data={
|
||||
"map": phase_state.flowchart_map,
|
||||
"original_draft": phase_state.original_draft_graph,
|
||||
},
|
||||
)
|
||||
# Ensure we have a flowchart for this agent — try in order:
|
||||
# 1. Already in phase_state (from planning workflow)
|
||||
# 2. Load from flowchart.json in the agent folder
|
||||
# 3. Synthesize from the runtime graph
|
||||
if phase_state is not None:
|
||||
if phase_state.original_draft_graph is None:
|
||||
# Try loading from file
|
||||
file_draft, file_map = _load_flowchart_file(resolved_path)
|
||||
if file_draft is not None:
|
||||
phase_state.original_draft_graph = file_draft
|
||||
phase_state.flowchart_map = file_map
|
||||
elif loaded_runtime is not None:
|
||||
# Synthesize from runtime graph
|
||||
goal = loaded_runtime.goal
|
||||
synth_draft, synth_map = _synthesize_draft_from_runtime(
|
||||
list(loaded_runtime.graph.nodes),
|
||||
list(loaded_runtime.graph.edges),
|
||||
agent_name=resolved_path.name,
|
||||
goal_name=goal.name if goal else "",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to emit flowchart map", exc_info=True)
|
||||
phase_state.original_draft_graph = synth_draft
|
||||
phase_state.flowchart_map = synth_map
|
||||
# Persist the synthesized flowchart so it's
|
||||
# available on next load without re-synthesis
|
||||
_save_flowchart_file(resolved_path, synth_draft, synth_map)
|
||||
|
||||
# Emit to frontend
|
||||
if (
|
||||
phase_state.original_draft_graph is not None
|
||||
and phase_state.flowchart_map is not None
|
||||
):
|
||||
bus = phase_state.event_bus
|
||||
if bus is not None:
|
||||
try:
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.FLOWCHART_MAP_UPDATED,
|
||||
stream_id="queen",
|
||||
data={
|
||||
"map": phase_state.flowchart_map,
|
||||
"original_draft": phase_state.original_draft_graph,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to emit flowchart map", exc_info=True)
|
||||
|
||||
# Switch to staging phase after successful load + validation
|
||||
if phase_state is not None:
|
||||
phase_state.agent_path = str(resolved_path)
|
||||
await phase_state.switch_to_staging()
|
||||
|
||||
worker_name = info.name if info else updated_session.worker_id
|
||||
|
||||
@@ -277,6 +277,8 @@ interface AgentBackendState {
|
||||
workerIsTyping: boolean;
|
||||
llmSnapshots: Record<string, string>;
|
||||
activeToolCalls: Record<string, { name: string; done: boolean; streamId: string }>;
|
||||
/** Agent folder path — set after scaffolding, used for credential queries */
|
||||
agentPath: string | null;
|
||||
/** Structured question text from ask_user with options */
|
||||
pendingQuestion: string | null;
|
||||
/** Predefined choices from ask_user (1-3 items); UI appends "Other" */
|
||||
@@ -302,6 +304,7 @@ function defaultAgentState(): AgentBackendState {
|
||||
draftGraph: null,
|
||||
originalDraft: null,
|
||||
flowchartMap: null,
|
||||
agentPath: null,
|
||||
workerRunState: "idle",
|
||||
currentExecutionId: null,
|
||||
nodeLogs: {},
|
||||
@@ -1069,16 +1072,33 @@ export default function Workspace() {
|
||||
// --- Fetch draft graph when a session is in planning phase ---
|
||||
// Covers initial load, tab switches, reconnects, and cold restores.
|
||||
const fetchedDraftSessionsRef = useRef<Set<string>>(new Set());
|
||||
const fetchedFlowchartMapSessionsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
for (const [agentType, state] of Object.entries(agentStates)) {
|
||||
if (!state.sessionId || !state.ready) continue;
|
||||
if (state.queenPhase !== "planning") continue;
|
||||
if (state.draftGraph) continue; // already have it
|
||||
if (fetchedDraftSessionsRef.current.has(state.sessionId)) continue;
|
||||
fetchedDraftSessionsRef.current.add(state.sessionId);
|
||||
graphsApi.draftGraph(state.sessionId).then(({ draft }) => {
|
||||
if (draft) updateAgentState(agentType, { draftGraph: draft });
|
||||
}).catch(() => {});
|
||||
|
||||
if (state.queenPhase === "planning") {
|
||||
// Fetch draft graph for planning phase
|
||||
if (state.draftGraph) continue;
|
||||
if (fetchedDraftSessionsRef.current.has(state.sessionId)) continue;
|
||||
fetchedDraftSessionsRef.current.add(state.sessionId);
|
||||
graphsApi.draftGraph(state.sessionId).then(({ draft }) => {
|
||||
if (draft) updateAgentState(agentType, { draftGraph: draft });
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// Fetch flowchart map for non-planning phases (staging, running, building)
|
||||
if (state.originalDraft) continue; // already have it
|
||||
if (fetchedFlowchartMapSessionsRef.current.has(state.sessionId)) continue;
|
||||
fetchedFlowchartMapSessionsRef.current.add(state.sessionId);
|
||||
graphsApi.flowchartMap(state.sessionId).then(({ map, original_draft }) => {
|
||||
if (original_draft) {
|
||||
updateAgentState(agentType, {
|
||||
flowchartMap: map,
|
||||
originalDraft: original_draft,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [agentStates, updateAgentState]);
|
||||
|
||||
@@ -1814,6 +1834,7 @@ export default function Workspace() {
|
||||
|
||||
case "queen_phase_changed": {
|
||||
const rawPhase = event.data?.phase as string;
|
||||
const eventAgentPath = (event.data?.agent_path as string) || null;
|
||||
const newPhase: "planning" | "building" | "staging" | "running" =
|
||||
rawPhase === "running" ? "running"
|
||||
: rawPhase === "staging" ? "staging"
|
||||
@@ -1824,21 +1845,29 @@ export default function Workspace() {
|
||||
queenBuilding: newPhase === "building",
|
||||
// Sync workerRunState so the RunButton reflects the phase
|
||||
workerRunState: newPhase === "running" ? "running" : "idle",
|
||||
// Clear draft graph once we leave planning; also clear dedup ref
|
||||
// so re-entering planning can refetch
|
||||
...(newPhase !== "planning" ? { draftGraph: null } : {}),
|
||||
// Clear draft graph once we leave planning; also clear dedup refs
|
||||
// so re-entering planning or re-fetching flowchart map works
|
||||
...(newPhase !== "planning" ? { draftGraph: null } : { originalDraft: null, flowchartMap: null }),
|
||||
// Store agent path for credential queries
|
||||
...(eventAgentPath ? { agentPath: eventAgentPath } : {}),
|
||||
});
|
||||
if (newPhase !== "planning") {
|
||||
{
|
||||
const sid = agentStates[agentType]?.sessionId;
|
||||
if (sid) {
|
||||
fetchedDraftSessionsRef.current.delete(sid);
|
||||
// Fetch the flowchart map (original draft + dissolution mapping)
|
||||
graphsApi.flowchartMap(sid).then(({ map, original_draft }) => {
|
||||
updateAgentState(agentType, {
|
||||
flowchartMap: map,
|
||||
originalDraft: original_draft,
|
||||
});
|
||||
}).catch(() => {});
|
||||
if (newPhase !== "planning") {
|
||||
fetchedDraftSessionsRef.current.delete(sid);
|
||||
fetchedFlowchartMapSessionsRef.current.delete(sid);
|
||||
// Fetch the flowchart map (original draft + dissolution mapping)
|
||||
graphsApi.flowchartMap(sid).then(({ map, original_draft }) => {
|
||||
updateAgentState(agentType, {
|
||||
flowchartMap: map,
|
||||
originalDraft: original_draft,
|
||||
});
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
fetchedDraftSessionsRef.current.delete(sid);
|
||||
fetchedFlowchartMapSessionsRef.current.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -2622,7 +2651,7 @@ export default function Workspace() {
|
||||
<CredentialsModal
|
||||
agentType={activeWorker}
|
||||
agentLabel={activeWorkerLabel}
|
||||
agentPath={credentialAgentPath || (!activeWorker.startsWith("new-agent") ? activeWorker : undefined)}
|
||||
agentPath={credentialAgentPath || activeAgentState?.agentPath || (!activeWorker.startsWith("new-agent") ? activeWorker : undefined)}
|
||||
open={credentialsOpen}
|
||||
onClose={() => { setCredentialsOpen(false); setCredentialAgentPath(null); setDismissedBanner(null); }}
|
||||
credentials={activeSession?.credentials || []}
|
||||
|
||||
Reference in New Issue
Block a user