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:
Dark-Alex-17 committed 2026-09-12 01:37:20 -06:00
1 parent 3599b0a861
commit f97a83f165
6 files changed
+339 -35

No files matched your search

+269 -11
View File
@@ -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);