fix: ensure flowchart existence

This commit is contained in:
Timothy
2026-03-12 16:40:18 -07:00
parent 86349c78d0
commit 43b759bf61
4 changed files with 322 additions and 50 deletions
@@ -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:
+32 -8
View File
@@ -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:
+219 -22
View File
@@ -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
+49 -20
View File
@@ -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 || []}