feat: merged normal agent config and graph agent configs into one file (either/or)
This commit is contained in:
+135
-8
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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::{
|
||||
|
||||
+1
-20
@@ -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<Option<Graph>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Default sampling temperature. Single-file mode only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
|
||||
/// Default sampling top-p. Single-file mode only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f64>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Global tools available to the agent's nodes. Single-file mode only.
|
||||
#[serde(default)]
|
||||
pub global_tools: Vec<String>,
|
||||
|
||||
/// MCP servers available to the agent's nodes. Single-file mode only.
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<String>,
|
||||
|
||||
/// Suggested prompts surfaced in the UI. Single-file mode only.
|
||||
#[serde(default)]
|
||||
pub conversation_starters: Vec<String>,
|
||||
|
||||
#[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<Node, _> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user