//! Graph planner runner: one attempt at decomposing an objective into a //! validated node DAG. //! //! Deliberately thin: the spawn plumbing (harness-internal subagent, //! verbatim-fork, fail-open model retry) is reused from //! [`goal_planner`](super::goal_planner) via the same //! [`GoalPlannerSpawner`] contract; this module only swaps the template //! and replaces "plan file exists" with "graph JSON parses and passes //! the static DAG gate" ([`graph_plan::parse_and_validate`]). //! //! Outcome split (both are loud, nothing is papered over): //! - [`GraphPlannerOutcome::Invalid`] — the planner wrote an artifact //! that failed validation. Retryable ONCE by the caller, feeding the //! precise validation error back as CONTEXT. //! - [`GraphPlannerOutcome::FailClosed`] — spawn/transport/missing-file //! failure. The caller pauses the graph; `/graph resume` retries. use std::path::Path; use std::sync::Arc; use super::goal_planner::{ GoalPlannerSpawner, RoleRenderedPrompt, SpawnError, parse_terminal_response, }; use super::goal_role_tools::RoleToolNames; use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES}; use super::graph_tracker::GraphNode; const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md"); const GRAPH_REPLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_replanner_prompt.md"); pub(crate) const GRAPH_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer"; pub(crate) const GRAPH_REPLANNER_SUBAGENT_DESCRIPTION: &str = "graph replanner"; #[derive(Debug)] pub(crate) enum GraphPlannerOutcome { /// Validated, canonicalized nodes (topo-ordered, final node appended). Planned(Vec), /// Artifact written but rejected by the static gate; retry once with /// the reason as feedback. Invalid { reason: String }, /// Infrastructure/spawn failure or missing artifact; pause the graph. FailClosed { reason: String }, } pub(crate) struct GraphPlannerInputs<'a> { pub objective: &'a str, /// Empty on the first attempt; the previous attempt's validation /// error on the retry. pub feedback: &'a str, pub graph_file: &'a Path, pub tool_names: &'a RoleToolNames, pub inherit_tool_names: &'a RoleToolNames, } /// Run one graph-planner attempt end to end: render, spawn, read the /// artifact (size-capped), validate, canonicalize. pub(crate) async fn run_graph_planner( spawner: Arc, inputs: GraphPlannerInputs<'_>, ) -> GraphPlannerOutcome { if let Some(parent) = inputs.graph_file.parent() && let Err(err) = tokio::fs::create_dir_all(parent).await { return GraphPlannerOutcome::FailClosed { reason: format!("failed to create graph dir {}: {err}", parent.display()), }; } // A stale artifact from a prior pass would satisfy the // missing-file guard below and be trusted as this pass's output. // Delete first; only NotFound is benign. if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await && err.kind() != std::io::ErrorKind::NotFound { return GraphPlannerOutcome::FailClosed { reason: format!("failed to clear stale graph artifact: {err}"), }; } let graph_file_str = inputs.graph_file.to_string_lossy(); let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str); let render = |tool_names: &RoleToolNames| -> String { let rendered = tool_names.apply(&with_graph_file); let mut full = String::with_capacity(rendered.len() + inputs.objective.len() + 256); full.push_str(&rendered); full.push_str("\n\nOBJECTIVE:\n"); full.push_str(inputs.objective); full.push_str("\n\nCONTEXT:\n"); full.push_str(inputs.feedback); full.push('\n'); full }; let prompt = RoleRenderedPrompt { primary: render(inputs.tool_names), fallback: render(inputs.inherit_tool_names), }; let spawn_id = uuid::Uuid::now_v7().to_string(); let response = match spawner.spawn_planner(&spawn_id, prompt).await { Ok(text) => text, Err(SpawnError::Transport(detail)) => { return GraphPlannerOutcome::FailClosed { reason: format!("graph planner transport error: {detail}"), }; } Err(SpawnError::Runtime { message, cancelled }) => { return GraphPlannerOutcome::FailClosed { reason: if cancelled { format!("graph planner aborted: {message}") } else { format!("graph planner runtime error: {message}") }, }; } }; match tokio::fs::metadata(inputs.graph_file).await { Ok(meta) if meta.is_file() && meta.len() > 0 => { if meta.len() > MAX_GRAPH_JSON_BYTES { return GraphPlannerOutcome::Invalid { reason: format!( "graph JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}", meta.len() ), }; } } _ => { tracing::info!( graph_file = %graph_file_str, terminal_token_ok = parse_terminal_response(&response), response_snippet = %response.chars().take(120).collect::(), "graph planner: graph file missing or empty; failing closed", ); return GraphPlannerOutcome::FailClosed { reason: "graph planner produced no graph file".to_owned(), }; } } let json = match tokio::fs::read_to_string(inputs.graph_file).await { Ok(json) => json, Err(err) => { return GraphPlannerOutcome::FailClosed { reason: format!("failed to read graph file: {err}"), }; } }; match graph_plan::parse_and_validate(&json, inputs.objective) { Ok(nodes) => GraphPlannerOutcome::Planned(nodes), Err(err) => GraphPlannerOutcome::Invalid { reason: err.to_string(), }, } } pub(crate) struct GraphReplannerInputs<'a> { pub objective: &'a str, /// Compact JSON of the existing nodes (id/title/status/deps). pub current_graph: &'a str, /// The queued discoveries, one per line with their origin node ids. pub discoveries: &'a str, /// On retry, the previous artifact's validation error. pub feedback: &'a str, pub graph_file: &'a Path, pub tool_names: &'a RoleToolNames, pub inherit_tool_names: &'a RoleToolNames, } /// Run one REPLAN attempt: render, spawn, read the artifact, validate /// against the existing graph (append-only). An empty `{"nodes": []}` /// appendix is the sanctioned "everything already covered" escape hatch /// and returns `Planned(vec![])`. pub(crate) async fn run_graph_replanner( spawner: Arc, existing: &[GraphNode], inputs: GraphReplannerInputs<'_>, ) -> GraphPlannerOutcome { if let Some(parent) = inputs.graph_file.parent() && let Err(err) = tokio::fs::create_dir_all(parent).await { return GraphPlannerOutcome::FailClosed { reason: format!("failed to create graph dir {}: {err}", parent.display()), }; } // A stale artifact from a prior pass would satisfy the // missing-file guard below and be trusted as this pass's output. // Delete first; only NotFound is benign. if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await && err.kind() != std::io::ErrorKind::NotFound { return GraphPlannerOutcome::FailClosed { reason: format!("failed to clear stale graph artifact: {err}"), }; } let graph_file_str = inputs.graph_file.to_string_lossy(); let with_graph_file = GRAPH_REPLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str); let render = |tool_names: &RoleToolNames| -> String { let rendered = tool_names.apply(&with_graph_file); format!( "{rendered}\n\nOBJECTIVE:\n{}\n\nCURRENT GRAPH:\n{}\n\nDISCOVERIES:\n{}\n\nCONTEXT:\n{}\n", inputs.objective, inputs.current_graph, inputs.discoveries, inputs.feedback ) }; let prompt = RoleRenderedPrompt { primary: render(inputs.tool_names), fallback: render(inputs.inherit_tool_names), }; let spawn_id = uuid::Uuid::now_v7().to_string(); let response = match spawner.spawn_planner(&spawn_id, prompt).await { Ok(text) => text, Err(SpawnError::Transport(detail)) => { return GraphPlannerOutcome::FailClosed { reason: format!("graph replanner transport error: {detail}"), }; } Err(SpawnError::Runtime { message, cancelled }) => { return GraphPlannerOutcome::FailClosed { reason: if cancelled { format!("graph replanner aborted: {message}") } else { format!("graph replanner runtime error: {message}") }, }; } }; match tokio::fs::metadata(inputs.graph_file).await { Ok(meta) if meta.is_file() && meta.len() > 0 => { if meta.len() > MAX_GRAPH_JSON_BYTES { return GraphPlannerOutcome::Invalid { reason: format!( "replan JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}", meta.len() ), }; } } _ => { tracing::info!( graph_file = %graph_file_str, terminal_token_ok = parse_terminal_response(&response), "graph replanner: artifact missing or empty; failing closed", ); return GraphPlannerOutcome::FailClosed { reason: "graph replanner produced no artifact".to_owned(), }; } } let json = match tokio::fs::read_to_string(inputs.graph_file).await { Ok(json) => json, Err(err) => { return GraphPlannerOutcome::FailClosed { reason: format!("failed to read replan artifact: {err}"), }; } }; // Escape hatch: an explicitly empty appendix means "already covered". if let Ok(v) = serde_json::from_str::(&json) && v.get("nodes") .and_then(|n| n.as_array()) .is_some_and(Vec::is_empty) { return GraphPlannerOutcome::Planned(Vec::new()); } match graph_plan::validate_replan(existing, &json) { Ok(nodes) => GraphPlannerOutcome::Planned(nodes), Err(err) => GraphPlannerOutcome::Invalid { reason: err.to_string(), }, } } /// Generic single-shot artifact pass: render `template` + `sections`, /// spawn the role child, enforce the stale-artifact/size/missing-file /// discipline, and return the artifact's raw JSON for the caller to /// validate. Shared by the optimizer (and any future boundary pass). pub(crate) struct ArtifactPassSpec<'a> { pub template: &'a str, pub sections: &'a str, pub graph_file: &'a Path, pub tool_names: &'a RoleToolNames, pub role: &'a str, } pub(crate) async fn run_graph_artifact_pass( spawner: Arc, spec: ArtifactPassSpec<'_>, ) -> Result { if let Some(parent) = spec.graph_file.parent() && let Err(err) = tokio::fs::create_dir_all(parent).await { return Err(format!("failed to create graph dir: {err}")); } if let Err(err) = tokio::fs::remove_file(spec.graph_file).await && err.kind() != std::io::ErrorKind::NotFound { return Err(format!("failed to clear stale artifact: {err}")); } let graph_file_str = spec.graph_file.to_string_lossy(); let rendered = spec .tool_names .apply(&spec.template.replace("{GRAPH_FILE}", &graph_file_str)); let prompt_text = format!("{rendered}\n\n{}", spec.sections); let prompt = RoleRenderedPrompt { primary: prompt_text.clone(), fallback: prompt_text, }; let spawn_id = uuid::Uuid::now_v7().to_string(); match spawner.spawn_planner(&spawn_id, prompt).await { Ok(_) => {} Err(SpawnError::Transport(detail)) => { return Err(format!("{} transport error: {detail}", spec.role)); } Err(SpawnError::Runtime { message, cancelled }) => { return Err(if cancelled { format!("{} aborted: {message}", spec.role) } else { format!("{} runtime error: {message}", spec.role) }); } } match tokio::fs::metadata(spec.graph_file).await { Ok(meta) if meta.is_file() && meta.len() > 0 && meta.len() <= MAX_GRAPH_JSON_BYTES => {} Ok(meta) if meta.len() > MAX_GRAPH_JSON_BYTES => { return Err(format!( "{} artifact is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}", spec.role, meta.len() )); } _ => return Err(format!("{} produced no artifact", spec.role)), } tokio::fs::read_to_string(spec.graph_file) .await .map_err(|err| format!("failed to read {} artifact: {err}", spec.role)) } #[cfg(test)] mod tests { use super::*; use crate::session::goal_role_tools::tests::summary_with; use kigi_tools::types::tool::ToolKind; use std::path::PathBuf; use std::sync::Mutex; enum MockReply { Done, Transport, Runtime { cancelled: bool }, } struct MockSpawner { response: MockReply, body: Option>, target: PathBuf, last_prompt: Mutex>, } #[async_trait::async_trait] impl GoalPlannerSpawner for MockSpawner { async fn spawn_planner( &self, _id: &str, prompt: RoleRenderedPrompt, ) -> Result { *self.last_prompt.lock().unwrap() = Some(prompt.primary.clone()); if let Some(body) = &self.body { std::fs::write(&self.target, body).unwrap(); } match &self.response { MockReply::Done => Ok("Done".to_owned()), MockReply::Transport => Err(SpawnError::Transport("channel closed".into())), MockReply::Runtime { cancelled } => Err(SpawnError::Runtime { message: "boom".into(), cancelled: *cancelled, }), } } } fn tool_names() -> RoleToolNames { RoleToolNames::from_summary(&summary_with(&[ (ToolKind::Read, "read_file"), (ToolKind::Search, "grep"), (ToolKind::List, "list_files"), (ToolKind::Write, "write"), ])) } /// Self-cleaning temp home per test: never leak dirs into the OS /// temp root (storage discipline — see AGENTS.md gates). fn tmp_graph_file(_name: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("graph.json"); (dir, path) } async fn run(spawner: MockSpawner, graph_file: &Path) -> GraphPlannerOutcome { let names = tool_names(); run_graph_planner( Arc::new(spawner), GraphPlannerInputs { objective: "build the thing", feedback: "", graph_file, tool_names: &names, inherit_tool_names: &names, }, ) .await } #[tokio::test] async fn valid_artifact_yields_canonical_nodes() { let (_tmp, target) = tmp_graph_file("valid"); let body = serde_json::json!({ "nodes": [ {"id": "core", "title": "Core", "spec": "core spec", "deps": []}, {"id": "ui", "title": "UI", "spec": "ui spec", "deps": ["core"]}, ] }) .to_string(); let spawner = MockSpawner { response: MockReply::Done, body: Some(body.into_bytes()), target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::Planned(nodes) => { assert_eq!(nodes.len(), 3, "2 planner nodes + appended final"); assert_eq!(nodes[2].id, crate::session::graph_tracker::FINAL_NODE_ID); } other => panic!("expected Planned, got {other:?}"), } } #[tokio::test] async fn prompt_embeds_objective_feedback_and_tool_names() { let (_tmp, target) = tmp_graph_file("prompt"); let spawner = MockSpawner { response: MockReply::Done, body: None, target: target.clone(), last_prompt: Mutex::new(None), }; let prompt_cell = std::sync::Arc::new(spawner); let names = tool_names(); let _ = run_graph_planner( prompt_cell.clone(), GraphPlannerInputs { objective: "OBJ-MARKER", feedback: "FEEDBACK-MARKER", graph_file: &target, tool_names: &names, inherit_tool_names: &names, }, ) .await; let prompt = prompt_cell.last_prompt.lock().unwrap().clone().unwrap(); assert!(prompt.contains("OBJ-MARKER")); assert!(prompt.contains("FEEDBACK-MARKER")); assert!(prompt.contains(&target.to_string_lossy().into_owned())); assert!(prompt.contains("read_file"), "placeholders rendered"); assert!(!prompt.contains("{READ_TOOL}"), "no leftover placeholder"); assert!(!prompt.contains("{GRAPH_FILE}"), "no leftover placeholder"); } #[tokio::test] async fn invalid_artifact_is_retryable_with_reason() { let (_tmp, target) = tmp_graph_file("invalid"); let spawner = MockSpawner { response: MockReply::Done, body: Some(br#"{"nodes":[{"id":"a","title":"A","spec":"s","deps":["a"]}]}"#.to_vec()), target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::Invalid { reason } => { assert!(reason.contains("depends on itself"), "{reason}"); } other => panic!("expected Invalid, got {other:?}"), } } #[tokio::test] async fn missing_artifact_fails_closed() { let (_tmp, target) = tmp_graph_file("missing"); let spawner = MockSpawner { response: MockReply::Done, body: None, target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::FailClosed { reason } => { assert!(reason.contains("no graph file"), "{reason}"); } other => panic!("expected FailClosed, got {other:?}"), } } #[tokio::test] async fn runtime_error_fails_closed() { let (_tmp, target) = tmp_graph_file("runtime"); let spawner = MockSpawner { response: MockReply::Runtime { cancelled: false }, body: None, target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::FailClosed { reason } => { assert!(reason.contains("runtime error"), "{reason}"); } other => panic!("expected FailClosed, got {other:?}"), } } #[tokio::test] async fn oversize_artifact_is_invalid_with_cap_in_reason() { let (_tmp, target) = tmp_graph_file("oversize"); let mut body = vec![b'x'; (MAX_GRAPH_JSON_BYTES as usize) + 1]; body[0] = b'{'; // content is irrelevant; the size gate fires first let spawner = MockSpawner { response: MockReply::Done, body: Some(body), target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::Invalid { reason } => { assert!(reason.contains("the cap is"), "{reason}"); } other => panic!("expected Invalid, got {other:?}"), } } #[tokio::test] async fn transport_error_fails_closed() { let (_tmp, target) = tmp_graph_file("transport"); let spawner = MockSpawner { response: MockReply::Transport, body: None, target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::FailClosed { reason } => { assert!(reason.contains("transport error"), "{reason}"); } other => panic!("expected FailClosed, got {other:?}"), } } #[tokio::test] async fn cancelled_runtime_error_reports_aborted() { let (_tmp, target) = tmp_graph_file("aborted"); let spawner = MockSpawner { response: MockReply::Runtime { cancelled: true }, body: None, target: target.clone(), last_prompt: Mutex::new(None), }; match run(spawner, &target).await { GraphPlannerOutcome::FailClosed { reason } => { assert!(reason.contains("aborted"), "{reason}"); } other => panic!("expected FailClosed, got {other:?}"), } } }