diff --git a/src/config/agent.rs b/src/config/agent.rs index fd4f69b..d12fe83 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -11,6 +11,7 @@ use crate::config::prompts::{ DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS, }; +use crate::graph::{Graph, GraphParser}; use crate::vault::SECRET_RE; use anyhow::{Context, Result}; use fancy_regex::Captures; @@ -97,10 +98,25 @@ impl Agent { let loaders = app.document_loaders.clone(); let rag_path = paths::agent_rag_file(name, DEFAULT_AGENT_NAME); let config_path = paths::agent_config_file(name); - let mut agent_config = if config_path.exists() { - AgentConfig::load(&config_path)? - } else { - bail!("Agent config file not found at '{}'", config_path.display()) + let graph_path = paths::agent_graph_file(name); + let mut agent_config = match (config_path.exists(), graph_path.exists()) { + (true, true) => bail!( + "Agent '{name}' has both config.yaml and graph.yaml. A graph agent \ + is defined by graph.yaml alone; a normal agent by config.yaml alone. \ + Remove one of the two files." + ), + (true, false) => AgentConfig::load(&config_path)?, + (false, true) => { + let parser = GraphParser::new(&agent_data_dir); + let graph = parser + .load_from_file(&graph_path) + .with_context(|| format!("Failed to load graph.yaml for agent '{name}'"))?; + AgentConfig::from_graph(name, &graph) + } + (false, false) => bail!( + "Agent '{name}' has neither a config.yaml nor a graph.yaml at '{}'", + agent_data_dir.display() + ), }; let mut functions = Functions::init_agent(name, &agent_config.global_tools)?; @@ -287,10 +303,13 @@ impl Agent { .display() .to_string() .into(); - value["config_file"] = paths::agent_config_file(&self.name) - .display() - .to_string() - .into(); + let config_path = paths::agent_config_file(&self.name); + let definition_file = if config_path.exists() { + config_path + } else { + paths::agent_graph_file(&self.name) + }; + value["config_file"] = definition_file.display().to_string().into(); let data = serde_yaml::to_string(&value)?; Ok(data) } @@ -650,6 +669,25 @@ impl AgentConfig { Ok(agent_config) } + pub fn from_graph(dir_name: &str, graph: &Graph) -> Self { + AgentConfig { + name: dir_name.to_string(), + model_id: graph.model.clone(), + temperature: graph.temperature, + top_p: graph.top_p, + agent_session: graph.agent_session.clone(), + description: graph.description.clone(), + global_tools: graph.global_tools.clone(), + mcp_servers: graph.mcp_servers.clone(), + conversation_starters: graph.conversation_starters.clone(), + can_spawn_agents: graph.has_agent_node(), + max_concurrent_agents: default_max_concurrent_agents(), + max_agent_depth: default_max_agent_depth(), + escalation_timeout: default_escalation_timeout(), + ..AgentConfig::default() + } + } + fn load_envs(&mut self, app: &AppConfig) { let name = &self.name; let with_prefix = |v: &str| normalize_env_name(&format!("{name}_{v}")); @@ -872,4 +910,93 @@ variables: assert!(config.inject_todo_instructions); assert!(config.inject_spawn_instructions); } + + #[test] + fn from_graph_maps_agent_level_fields() { + let yaml = r#" +name: graph_name_ignored +description: A graph agent +model: anthropic:claude-sonnet-4-6 +temperature: 0.3 +top_p: 0.8 +agent_session: temp +global_tools: + - fetch_pdf.sh +mcp_servers: + - pubmed-search +conversation_starters: + - "Start here" +start: e +nodes: + e: + id: e + type: end + output: done +"#; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + let config = AgentConfig::from_graph("my-agent-dir", &graph); + + assert_eq!(config.name, "my-agent-dir"); + assert_eq!(config.description, "A graph agent"); + assert_eq!( + config.model_id.as_deref(), + Some("anthropic:claude-sonnet-4-6") + ); + assert_eq!(config.temperature, Some(0.3)); + assert_eq!(config.top_p, Some(0.8)); + assert_eq!(config.agent_session.as_deref(), Some("temp")); + assert_eq!(config.global_tools, vec!["fetch_pdf.sh"]); + assert_eq!(config.mcp_servers, vec!["pubmed-search"]); + assert_eq!(config.conversation_starters, vec!["Start here"]); + } + + #[test] + fn from_graph_derives_can_spawn_agents_from_agent_nodes() { + let with_agent = r#" +name: g +start: a +nodes: + a: + id: a + type: agent + agent: helper + prompt: hi + next: e + e: + id: e + type: end + output: done +"#; + let graph: Graph = serde_yaml::from_str(with_agent).unwrap(); + assert!(AgentConfig::from_graph("d", &graph).can_spawn_agents); + + let no_agent = + "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n"; + let graph: Graph = serde_yaml::from_str(no_agent).unwrap(); + assert!(!AgentConfig::from_graph("d", &graph).can_spawn_agents); + } + + #[test] + fn from_graph_keeps_defaults_for_llm_loop_fields() { + let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n"; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + let config = AgentConfig::from_graph("d", &graph); + + // LLM-loop concepts a graph agent does not have: left at Default. + assert!(!config.auto_continue); + assert!(config.instructions.is_empty()); + assert!(config.documents.is_empty()); + assert!(!config.inject_todo_instructions); + assert!(!config.inject_spawn_instructions); + assert_eq!(config.max_auto_continues, 0); + assert_eq!(config.summarization_threshold, 0); + + // Consumed by graph execution: kept at their real defaults. + assert_eq!( + config.max_concurrent_agents, + default_max_concurrent_agents() + ); + assert_eq!(config.max_agent_depth, default_max_agent_depth()); + assert_eq!(config.escalation_timeout, default_escalation_timeout()); + } } diff --git a/src/config/paths.rs b/src/config/paths.rs index eeec88c..dc1f996 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -128,7 +128,7 @@ pub fn agent_data_dir(name: &str) -> PathBuf { } } -pub fn agent_graph_path(agent_name: &str) -> PathBuf { +pub fn agent_graph_file(agent_name: &str) -> PathBuf { agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME) } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index be8558c..4986041 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1410,20 +1410,26 @@ impl RequestContext { Some(agent) => agent.name(), None => bail!("No agent"), }; - let agent_config_path = paths::agent_config_file(agent_name); - ensure_parent_exists(&agent_config_path)?; - if !agent_config_path.exists() { + let config_path = paths::agent_config_file(agent_name); + let graph_path = paths::agent_graph_file(agent_name); + let target_path = if !config_path.exists() && graph_path.exists() { + graph_path + } else { + config_path + }; + ensure_parent_exists(&target_path)?; + if !target_path.exists() { std::fs::write( - &agent_config_path, + &target_path, "# see https://github.com/Dark-Alex-17/loki/blob/main/config.agent.example.yaml\n", ) - .with_context(|| format!("Failed to write to '{}'", agent_config_path.display()))?; + .with_context(|| format!("Failed to write to '{}'", target_path.display()))?; } let editor = app.editor()?; - edit_file(&editor, &agent_config_path)?; + edit_file(&editor, &target_path)?; println!( "NOTE: Remember to reload the agent if there are changes made to '{}'", - agent_config_path.display() + target_path.display() ); Ok(()) } diff --git a/src/graph/dispatch.rs b/src/graph/dispatch.rs index d9a6a94..50bceeb 100644 --- a/src/graph/dispatch.rs +++ b/src/graph/dispatch.rs @@ -34,7 +34,7 @@ pub async fn run_active_agent_graph( log::info!("Agent '{agent_name}' has graph.yaml; routing to graph executor"); let agent_dir = paths::agent_data_dir(&agent_name); - let graph_path = paths::agent_graph_path(&agent_name); + let graph_path = paths::agent_graph_file(&agent_name); let parser = GraphParser::new(&agent_dir); let mut graph = parser diff --git a/src/graph/mod.rs b/src/graph/mod.rs index 704f4e2..354e61a 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -17,7 +17,7 @@ pub use agent::AgentNodeExecutor; pub use dispatch::{active_agent_graph_name, run_active_agent_graph}; pub use executor::GraphExecutor; pub use llm::LlmNodeExecutor; -pub use parser::{GraphParser, agent_has_graph, load_agent_graph}; +pub use parser::{GraphParser, agent_has_graph}; pub use script::ScriptExecutor; pub use state::{StateManager, StateRepresentation}; pub use types::{ diff --git a/src/graph/parser.rs b/src/graph/parser.rs index e2fd513..345fdb0 100644 --- a/src/graph/parser.rs +++ b/src/graph/parser.rs @@ -125,20 +125,7 @@ fn enhance_yaml_error(error: serde_yaml::Error) -> Error { /// Returns true if the named agent has a `graph.yaml` in its data directory. pub fn agent_has_graph(agent_name: &str) -> bool { - paths::agent_graph_path(agent_name).exists() -} - -/// Load `graph.yaml` from the named agent's data directory. Returns `Ok(None)` -/// if no graph file exists. -pub fn load_agent_graph(agent_name: &str) -> Result> { - let graph_path = paths::agent_graph_path(agent_name); - if !graph_path.exists() { - return Ok(None); - } - - let parser = GraphParser::new(paths::agent_data_dir(agent_name)); - let graph = parser.load_from_file(&graph_path)?; - Ok(Some(graph)) + paths::agent_graph_file(agent_name).exists() } #[cfg(test)] @@ -448,10 +435,4 @@ nodes: fn agent_has_graph_false_for_unknown_agent() { assert!(!agent_has_graph("__nonexistent_agent_for_test__")); } - - #[test] - fn load_agent_graph_returns_none_when_absent() { - let result = load_agent_graph("__nonexistent_agent_for_test__").unwrap(); - assert!(result.is_none()); - } } diff --git a/src/graph/types.rs b/src/graph/types.rs index 827fc14..1221089 100644 --- a/src/graph/types.rs +++ b/src/graph/types.rs @@ -17,6 +17,36 @@ pub struct Graph { #[serde(default = "default_schema_version")] pub version: String, + /// Default chat model for the agent. Used when an `llm` node does not + /// set its own `model`. Consulted in single-file mode (an agent with + /// a `graph.yaml` and no `config.yaml`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Default sampling temperature. Single-file mode only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + + /// Default sampling top-p. Single-file mode only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_p: Option, + + /// Session to start the agent in (e.g. `temp`). Single-file mode only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session: Option, + + /// Global tools available to the agent's nodes. Single-file mode only. + #[serde(default)] + pub global_tools: Vec, + + /// MCP servers available to the agent's nodes. Single-file mode only. + #[serde(default)] + pub mcp_servers: Vec, + + /// Suggested prompts surfaced in the UI. Single-file mode only. + #[serde(default)] + pub conversation_starters: Vec, + #[serde(default)] pub settings: GraphSettings, @@ -40,6 +70,15 @@ impl Graph { pub fn node_ids(&self) -> Vec<&str> { self.nodes.keys().map(|s| s.as_str()).collect() } + + /// Returns true if any node is an `agent`-type node. Used to derive + /// `can_spawn_agents` when synthesizing an agent config from a + /// single-file graph. + pub fn has_agent_node(&self) -> bool { + self.nodes + .values() + .any(|n| matches!(n.node_type, NodeType::Agent(_))) + } } fn default_schema_version() -> String { @@ -734,4 +773,76 @@ instructions: "System only — no user prompt." let result: std::result::Result = serde_yaml::from_str(yaml); assert!(result.is_err()); } + + #[test] + fn graph_parses_agent_level_top_level_fields() { + let yaml = r#" +name: single_file +start: e +model: anthropic:claude-sonnet-4-6 +temperature: 0.2 +top_p: 0.9 +agent_session: temp +global_tools: + - web_search_loki.sh +mcp_servers: + - pubmed-search +conversation_starters: + - "Look up 2160-0" +nodes: + e: + id: e + type: end + output: done +"#; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(graph.model.as_deref(), Some("anthropic:claude-sonnet-4-6")); + assert_eq!(graph.temperature, Some(0.2)); + assert_eq!(graph.top_p, Some(0.9)); + assert_eq!(graph.agent_session.as_deref(), Some("temp")); + assert_eq!(graph.global_tools, vec!["web_search_loki.sh"]); + assert_eq!(graph.mcp_servers, vec!["pubmed-search"]); + assert_eq!(graph.conversation_starters, vec!["Look up 2160-0"]); + } + + #[test] + fn graph_agent_level_fields_default_when_absent() { + let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n"; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + assert!(graph.model.is_none()); + assert!(graph.temperature.is_none()); + assert!(graph.top_p.is_none()); + assert!(graph.agent_session.is_none()); + assert!(graph.global_tools.is_empty()); + assert!(graph.mcp_servers.is_empty()); + assert!(graph.conversation_starters.is_empty()); + } + + #[test] + fn has_agent_node_detects_agent_nodes() { + let with_agent = r#" +name: g +start: a +nodes: + a: + id: a + type: agent + agent: helper + prompt: hi + next: e + e: + id: e + type: end + output: done +"#; + let graph: Graph = serde_yaml::from_str(with_agent).unwrap(); + assert!(graph.has_agent_node()); + } + + #[test] + fn has_agent_node_false_without_agent_nodes() { + let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n"; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + assert!(!graph.has_agent_node()); + } } diff --git a/src/graph/validator.rs b/src/graph/validator.rs index 59d1afa..231d363 100644 --- a/src/graph/validator.rs +++ b/src/graph/validator.rs @@ -195,16 +195,20 @@ impl GraphValidator { for (node_id, node) in &graph.nodes { if let NodeType::Agent(a) = &node.node_type { let agent_dir = paths::agent_data_dir(&a.agent); - let config_path = paths::agent_config_file(&a.agent); + let has_config = paths::agent_config_file(&a.agent).exists(); + let has_graph = paths::agent_graph_file(&a.agent).exists(); if !agent_dir.exists() { result.error(ValidationError::with_node( node_id, format!("Agent '{}' not found (directory missing)", a.agent), )); - } else if !config_path.exists() { + } else if !has_config && !has_graph { result.error(ValidationError::with_node( node_id, - format!("Agent '{}' has no config.yaml", a.agent), + format!( + "Agent '{}' has neither a config.yaml nor a graph.yaml", + a.agent + ), )); } } @@ -351,6 +355,13 @@ mod tests { name: "t".into(), description: String::new(), version: "1.0".into(), + model: None, + temperature: None, + top_p: None, + agent_session: None, + global_tools: Vec::new(), + mcp_servers: Vec::new(), + conversation_starters: Vec::new(), settings: GraphSettings::default(), initial_state: HashMap::new(), start: start.into(),