feat(agents): allow passing agent variables to agent__spawn and agent__task_create
- agent__spawn accepts an optional variables object (string->string); values overlay the parent-inherited agent variables per key, with inheritance preserved as the fallback layer - unknown variable names, non-string values, and non-object shapes return self-correcting status:error JSON listing the agent's declared variables - spawned children can no longer trigger the interactive variable prompt: a new interactive gate in Agent::init_agent_variables routes missing required variables to the existing bail path while leaving CLI/REPL prompting and --info skip semantics unchanged - agent__task_create persists variables on the task, agent__task_list echoes them, and task auto-dispatch threads them into the spawn - tool schemas and built-in spawn docs updated; unit tests extended in agents.rs and taskqueue.rs
This commit is contained in:
6 files changed
+339
-35
No files matched your search
+269
-11
@@ -2,8 +2,9 @@ use super::todo::TODO_FUNCTION_PREFIX;
|
||||
use super::{FunctionDeclaration, JsonSchema};
|
||||
use crate::client::{Model, ModelType, call_chat_completions};
|
||||
use crate::config::{
|
||||
Agent, AppState, Input, RequestContext, Role, RoleLike, default_max_agent_depth,
|
||||
effective_max_concurrent_jobs, jobs_enabled, list_agents_with_descriptions,
|
||||
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
|
||||
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
|
||||
list_agents_with_descriptions,
|
||||
};
|
||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
|
||||
use crate::supervisor::notification::agent_notification;
|
||||
@@ -39,6 +40,73 @@ fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_variables_arg(args: &Value) -> Result<Option<AgentVariables>, String> {
|
||||
let Some(value) = args.get("variables") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(obj) = value.as_object() else {
|
||||
return Err(
|
||||
"'variables' must be a JSON object mapping variable names to string values, e.g. {\"pr\": \"1234\"}".to_string(),
|
||||
);
|
||||
};
|
||||
let mut variables = AgentVariables::new();
|
||||
for (key, val) in obj {
|
||||
let Some(val) = val.as_str() else {
|
||||
return Err(format!("'variables.{key}' must be a string value"));
|
||||
};
|
||||
variables.insert(key.clone(), val.to_string());
|
||||
}
|
||||
Ok(Some(variables))
|
||||
}
|
||||
|
||||
fn validate_spawn_variables(
|
||||
agent_name: &str,
|
||||
declared: &[AgentVariable],
|
||||
provided: &AgentVariables,
|
||||
) -> Result<(), String> {
|
||||
let unknown: Vec<&str> = provided
|
||||
.keys()
|
||||
.filter(|key| !declared.iter().any(|v| v.name == **key))
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
if unknown.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if declared.is_empty() {
|
||||
return Err(format!(
|
||||
"Agent '{agent_name}' declares no variables; remove the 'variables' argument."
|
||||
));
|
||||
}
|
||||
let declared_list = declared
|
||||
.iter()
|
||||
.map(|v| format!("{} ({})", v.name, v.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!(
|
||||
"Unknown variable(s) {unknown:?} for agent '{agent_name}'. Declared variables: {declared_list}"
|
||||
))
|
||||
}
|
||||
|
||||
fn merge_spawn_variables(inherited: &mut Option<AgentVariables>, provided: AgentVariables) {
|
||||
if provided.is_empty() {
|
||||
return;
|
||||
}
|
||||
inherited
|
||||
.get_or_insert_with(AgentVariables::new)
|
||||
.extend(provided);
|
||||
}
|
||||
|
||||
fn dispatch_spawn_args(agent: &str, prompt: &str, variables: Option<&AgentVariables>) -> Value {
|
||||
let mut spawn_args = json!({
|
||||
"agent": agent,
|
||||
"prompt": prompt,
|
||||
});
|
||||
if let Some(variables) = variables {
|
||||
spawn_args["variables"] = json!(variables);
|
||||
}
|
||||
spawn_args
|
||||
}
|
||||
|
||||
fn is_job_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) -> bool {
|
||||
id.starts_with("job_") || supervisor.is_some_and(|sup| sup.read().has_job(id))
|
||||
}
|
||||
@@ -265,6 +333,14 @@ pub fn agent_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"variables".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
description: Some("Values for the target agent's declared variables (its config/graph `variables:` list), e.g. {\"pr\": \"1234\"}. Overrides values inherited from the parent. String values only. Note: if the target agent resumes an existing shared session, the session's stored variable values take precedence and these are ignored.".into()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
])),
|
||||
required: Some(vec!["agent".to_string(), "prompt".to_string()]),
|
||||
..Default::default()
|
||||
@@ -410,6 +486,14 @@ pub fn agent_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"variables".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
description: Some("Values for the auto-spawned agent's declared variables, e.g. {\"pr\": \"1234\"}. Passed to `agent__spawn` at dispatch time. String values only.".into()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
])),
|
||||
required: Some(vec!["subject".to_string()]),
|
||||
..Default::default()
|
||||
@@ -918,6 +1002,15 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| anyhow!("'prompt' is required"))?
|
||||
.to_string();
|
||||
let _task_id = args.get("task_id").and_then(Value::as_str);
|
||||
let variables = match parse_variables_arg(args) {
|
||||
Ok(v) => v,
|
||||
Err(message) => {
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": message,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(parent) = ctx.agent.as_ref()
|
||||
&& !agent_permitted(parent.spawnable_agents(), &agent_name)
|
||||
@@ -995,6 +1088,16 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(provided) = variables.as_ref()
|
||||
&& let Err(message) =
|
||||
validate_spawn_variables(&agent_name, agent.defined_variables(), provided)
|
||||
{
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": message,
|
||||
}));
|
||||
}
|
||||
|
||||
let agent_mcp_servers = agent.mcp_server_names().to_vec();
|
||||
let session = agent.agent_session().map(|v| v.to_string());
|
||||
let child_jobs_enabled = jobs_enabled(Some(&agent), app_config.as_ref());
|
||||
@@ -1020,6 +1123,9 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
Supervisor::new(max_concurrent_agents, max_depth).with_max_concurrent_jobs(max_jobs),
|
||||
)));
|
||||
}
|
||||
if let Some(provided) = variables {
|
||||
merge_spawn_variables(&mut child_ctx.agent_variables, provided);
|
||||
}
|
||||
|
||||
if let Some(session) = session {
|
||||
child_ctx
|
||||
@@ -1605,6 +1711,16 @@ fn handle_task_create(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
bail!("'prompt' is required when 'agent' is set");
|
||||
}
|
||||
|
||||
let variables = match parse_variables_arg(args) {
|
||||
Ok(v) => v,
|
||||
Err(message) => {
|
||||
return Ok(json!({
|
||||
"status": "error",
|
||||
"message": message,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let supervisor = ctx
|
||||
.supervisor
|
||||
.as_ref()
|
||||
@@ -1617,6 +1733,7 @@ fn handle_task_create(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
description.to_string(),
|
||||
dispatch_agent.clone(),
|
||||
task_prompt,
|
||||
variables,
|
||||
);
|
||||
|
||||
let mut dep_errors = vec![];
|
||||
@@ -1664,6 +1781,7 @@ fn handle_task_list(ctx: &mut RequestContext) -> Result<Value> {
|
||||
"blocks": t.blocks.iter().collect::<Vec<_>>(),
|
||||
"agent": t.dispatch_agent,
|
||||
"prompt": t.prompt,
|
||||
"variables": t.variables,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1688,7 +1806,7 @@ async fn handle_task_complete(ctx: &mut RequestContext, args: &Value) -> Result<
|
||||
let newly_runnable_ids = sup.task_queue_mut().complete(task_id);
|
||||
|
||||
let mut newly_runnable = Vec::new();
|
||||
let mut to_dispatch: Vec<(String, String, String)> = Vec::new();
|
||||
let mut to_dispatch: Vec<(String, String, String, Option<AgentVariables>)> = Vec::new();
|
||||
|
||||
for id in &newly_runnable_ids {
|
||||
if let Some(t) = sup.task_queue().get(id) {
|
||||
@@ -1700,15 +1818,20 @@ async fn handle_task_complete(ctx: &mut RequestContext, args: &Value) -> Result<
|
||||
}));
|
||||
|
||||
if let (Some(agent), Some(prompt)) = (&t.dispatch_agent, &t.prompt) {
|
||||
to_dispatch.push((id.clone(), agent.clone(), prompt.clone()));
|
||||
to_dispatch.push((
|
||||
id.clone(),
|
||||
agent.clone(),
|
||||
prompt.clone(),
|
||||
t.variables.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut dispatchable = Vec::new();
|
||||
for (tid, agent, prompt) in to_dispatch {
|
||||
for (tid, agent, prompt, variables) in to_dispatch {
|
||||
if sup.task_queue_mut().claim(&tid, &format!("auto:{agent}")) {
|
||||
dispatchable.push((agent, prompt));
|
||||
dispatchable.push((agent, prompt, variables));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1716,11 +1839,8 @@ async fn handle_task_complete(ctx: &mut RequestContext, args: &Value) -> Result<
|
||||
};
|
||||
|
||||
let mut spawned = Vec::new();
|
||||
for (agent, prompt) in &dispatchable {
|
||||
let spawn_args = json!({
|
||||
"agent": agent,
|
||||
"prompt": prompt,
|
||||
});
|
||||
for (agent, prompt, variables) in &dispatchable {
|
||||
let spawn_args = dispatch_spawn_args(agent, prompt, variables.as_ref());
|
||||
match handle_spawn(ctx, &spawn_args).await {
|
||||
Ok(result) => {
|
||||
let agent_id = result
|
||||
@@ -2935,6 +3055,30 @@ mod tests {
|
||||
assert_eq!(result["auto_dispatch"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_task_create_stores_variables() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result = handle_task_create(
|
||||
&mut ctx,
|
||||
&json!({"subject": "Auto task", "agent": "coder", "prompt": "do it", "variables": {"pr": "1234"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["status"], "ok");
|
||||
|
||||
let list = handle_task_list(&mut ctx).unwrap();
|
||||
let tasks = list["tasks"].as_array().unwrap();
|
||||
assert_eq!(tasks[0]["variables"]["pr"], "1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_task_create_rejects_non_object_variables() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result =
|
||||
handle_task_create(&mut ctx, &json!({"subject": "Bad", "variables": ["pr"]})).unwrap();
|
||||
assert_eq!(result["status"], "error");
|
||||
assert!(result["message"].as_str().unwrap().contains("JSON object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_task_create_agent_without_prompt_errors() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
@@ -3635,6 +3779,120 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_spawn_non_object_variables_errors() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result = run_async(handle_spawn(
|
||||
&mut ctx,
|
||||
&json!({"agent": "x", "prompt": "p", "variables": "pr=1234"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result["status"], "error");
|
||||
assert!(result["message"].as_str().unwrap().contains("JSON object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_spawn_non_string_variable_value_errors() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result = run_async(handle_spawn(
|
||||
&mut ctx,
|
||||
&json!({"agent": "x", "prompt": "p", "variables": {"pr": 1234}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result["status"], "error");
|
||||
assert!(
|
||||
result["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("'variables.pr'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variables_arg_absent_returns_none() {
|
||||
assert_eq!(parse_variables_arg(&json!({})).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variables_arg_accepts_object_of_strings() {
|
||||
let parsed = parse_variables_arg(&json!({"variables": {"pr": "1234", "repo": "coyote"}}))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(parsed.get("pr").map(String::as_str), Some("1234"));
|
||||
assert_eq!(parsed.get("repo").map(String::as_str), Some("coyote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_spawn_variables_unknown_name_lists_declared() {
|
||||
let declared = vec![AgentVariable {
|
||||
name: "pr".into(),
|
||||
description: "PR number".into(),
|
||||
..Default::default()
|
||||
}];
|
||||
let provided = AgentVariables::from([("typo".to_string(), "x".to_string())]);
|
||||
let err = validate_spawn_variables("coder", &declared, &provided).unwrap_err();
|
||||
assert!(err.contains("typo"));
|
||||
assert!(err.contains("pr (PR number)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_spawn_variables_none_declared_errors() {
|
||||
let provided = AgentVariables::from([("pr".to_string(), "1".to_string())]);
|
||||
let err = validate_spawn_variables("coder", &[], &provided).unwrap_err();
|
||||
assert!(err.contains("declares no variables"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_spawn_variables_known_names_ok() {
|
||||
let declared = vec![AgentVariable {
|
||||
name: "pr".into(),
|
||||
description: "PR number".into(),
|
||||
..Default::default()
|
||||
}];
|
||||
let provided = AgentVariables::from([("pr".to_string(), "1".to_string())]);
|
||||
assert!(validate_spawn_variables("coder", &declared, &provided).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_spawn_variables_overrides_inherited_per_key() {
|
||||
let mut inherited = Some(AgentVariables::from([
|
||||
("pr".to_string(), "1".to_string()),
|
||||
("repo".to_string(), "coyote".to_string()),
|
||||
]));
|
||||
merge_spawn_variables(
|
||||
&mut inherited,
|
||||
AgentVariables::from([("pr".to_string(), "2".to_string())]),
|
||||
);
|
||||
let merged = inherited.unwrap();
|
||||
assert_eq!(merged.get("pr").map(String::as_str), Some("2"));
|
||||
assert_eq!(merged.get("repo").map(String::as_str), Some("coyote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_spawn_variables_seeds_when_nothing_inherited() {
|
||||
let mut inherited = None;
|
||||
merge_spawn_variables(
|
||||
&mut inherited,
|
||||
AgentVariables::from([("pr".to_string(), "2".to_string())]),
|
||||
);
|
||||
assert_eq!(inherited.unwrap().get("pr").map(String::as_str), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_spawn_args_includes_variables() {
|
||||
let variables = AgentVariables::from([("pr".to_string(), "1234".to_string())]);
|
||||
let args = dispatch_spawn_args("coder", "do it", Some(&variables));
|
||||
assert_eq!(args["agent"], "coder");
|
||||
assert_eq!(args["prompt"], "do it");
|
||||
assert_eq!(args["variables"]["pr"], "1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_spawn_args_omits_absent_variables() {
|
||||
let args = dispatch_spawn_args("coder", "do it", None);
|
||||
assert!(args.get("variables").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_spawn_at_capacity_errors() {
|
||||
let mut ctx = ctx_with_supervisor(1, 3);
|
||||
|
||||
Reference in new issue
Block a user