Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ agent "<identity>" {
// role "worker"
// supervisor "<supervisor-bus-id>"
env { ST_AGENT "<host>.<identity>" }
command #"exec codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust '<boot prompt>'"#
argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "<boot prompt>"
ding

render {
Expand All @@ -123,6 +123,12 @@ agent "<identity>" {
}
```

`argv` launches its first value directly with the remaining values as arguments. It resolves a bare
program such as `codex` through the task environment's `PATH`, preserves argument boundaries, and
does not introduce a shell. Use `command #"..."#` instead when the task intentionally needs shell
syntax such as pipelines, redirects, or variable expansion; `command` continues to run under
`sh -c`. A runnable task must declare exactly one of `argv` or `command`.

### Scheduled work is coming soon, not implemented

st2 does not parse or run scheduled entries today. The intended direction starts with a declarative
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-spec/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ fn resolve_spec(
(None, None) => None,
};

let spec = raw.into_agent_spec(identity, host, path.to_path_buf());
let spec = raw.into_agent_spec(identity, host, path.to_path_buf())?;
Ok(Some((spec, warnings)))
}

Expand Down
33 changes: 22 additions & 11 deletions crates/agent-spec/src/kdl_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ fn arg_string(node: &KdlNode) -> Option<String> {
node.get(0).and_then(|v| v.as_string()).map(String::from)
}

/// Every positional argument of a node, as strings.
fn argv(node: &KdlNode) -> anyhow::Result<Vec<String>> {
node.entries()
.iter()
.filter(|entry| entry.name().is_none())
.map(|entry| {
entry
.value()
.as_string()
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("`argv` accepts only string arguments"))
})
.collect()
}

/// First positional argument as a bool (`#true`/`#false`), defaulting to `false`.
fn arg_bool(node: &KdlNode) -> bool {
node.get(0).and_then(|v| v.as_bool()).unwrap_or(false)
Expand Down Expand Up @@ -70,28 +85,23 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
"keep" => raw.keep = arg_bool(child),
"restart" => raw.restart = Some(restart_node_to_raw(child)),
"command" => raw.command = arg_string(child),
"argv" => raw.argv = Some(argv(child)?),
"ding" => raw.ding = true,
"env" => {}
"pty" => {
if let Some(name) = arg_string(child) {
raw.pty.insert(name, task_node_to_raw(child));
raw.pty.insert(name, task_node_to_raw(child)?);
}
}
"exec" => {
if let Some(name) = arg_string(child) {
raw.exec.insert(name, task_node_to_raw(child));
raw.exec.insert(name, task_node_to_raw(child)?);
}
}
// meta, harness, model, persona, permissions, transport, strategy, … — ignored.
_ => {}
}
}
if raw.command.is_some() && raw.pty.contains_key("agent") {
anyhow::bail!(
"agent '{}' declares both compact `command` and `pty \"agent\"`; choose one form",
raw.identity.as_deref().unwrap_or("<unnamed>")
);
}
if raw.ding && raw.exec.contains_key("ding") {
anyhow::bail!(
"agent '{}' declares both compact `ding` and `exec \"ding\"`; choose one form",
Expand All @@ -118,16 +128,17 @@ fn restart_node_to_raw(node: &KdlNode) -> RawRestart {
r
}

fn task_node_to_raw(node: &KdlNode) -> RawTask {
fn task_node_to_raw(node: &KdlNode) -> anyhow::Result<RawTask> {
let mut t = RawTask::default();
let Some(children) = node.children() else {
return t;
return Ok(t);
};

for child in children.nodes() {
match child.name().value() {
"id" => t.id = arg_string(child),
"command" => t.command = arg_string(child),
"argv" => t.argv = Some(argv(child)?),
"cwd" => t.cwd = arg_string(child),
"keep" => t.keep = arg_bool(child),
// `tags role="agent" "st.network"="$CATALOG"` — properties on the node.
Expand All @@ -145,7 +156,7 @@ fn task_node_to_raw(node: &KdlNode) -> RawTask {
_ => {}
}
}
t
Ok(t)
}

fn env_node_to_raw(node: &KdlNode) -> std::collections::BTreeMap<String, String> {
Expand Down
77 changes: 63 additions & 14 deletions crates/agent-spec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ pub struct Task {
pub name: String,
/// Explicit on-disk id. `None` → `<host>.<identity>.<name>` at spawn.
pub id: Option<String>,
/// The exec command line, run verbatim under `sh -c`. No command → not a launch target.
/// A shell program, run verbatim under `sh -c`.
pub command: Option<String>,
/// A direct program invocation. Element 0 is the program and the rest are its arguments.
///
/// Mutually exclusive with [`Task::command`]. Neither field means this is not a launch target.
pub argv: Option<Vec<String>>,
/// Working dir; `None` → the agent's `workspace`, else the spec file's directory.
pub cwd: Option<String>,
/// Arbitrary metadata (values are `$`-expanded at spawn).
Expand Down Expand Up @@ -135,12 +139,12 @@ impl AgentSpec {
self.host.as_deref().unwrap_or(this_host)
}

/// True once at least one authored task carries an explicit `command` (i.e. the job was
/// rendered). A generated sidecar cannot make an otherwise-empty job runnable.
/// True once at least one authored task carries an explicit shell command or direct argv (i.e.
/// the job was rendered). A generated sidecar cannot make an otherwise-empty job runnable.
pub fn is_runnable(&self) -> bool {
self.tasks
.iter()
.any(|task| !task.derived && task.command.is_some())
.any(|task| !task.derived && (task.command.is_some() || task.argv.is_some()))
}

/// The restart policy in effect (declared, else the runner default).
Expand Down Expand Up @@ -197,6 +201,8 @@ pub(crate) struct RawSpec {
pub restart: Option<RawRestart>,
/// Compact catalog form: the agent itself is one pty carrying this command.
pub command: Option<String>,
/// Compact catalog form: the agent itself is one pty launched directly with this argv.
pub argv: Option<Vec<String>>,
/// Compact catalog form: environment inherited by the agent pty and every sidecar.
#[serde(default)]
pub env: BTreeMap<String, String>,
Expand All @@ -215,6 +221,7 @@ pub(crate) struct RawSpec {
pub(crate) struct RawTask {
pub id: Option<String>,
pub command: Option<String>,
pub argv: Option<Vec<String>>,
pub cwd: Option<String>,
#[serde(default)]
pub tags: BTreeMap<String, String>,
Expand Down Expand Up @@ -261,6 +268,7 @@ impl RawSpec {
self.identity.is_some()
|| self.job_type.is_some()
|| self.command.is_some()
|| self.argv.is_some()
|| self.ding
|| !self.pty.is_empty()
|| !self.exec.is_empty()
Expand All @@ -272,18 +280,29 @@ impl RawSpec {
identity: String,
host: Option<String>,
path: PathBuf,
) -> AgentSpec {
) -> anyhow::Result<AgentSpec> {
validate_launch(
&identity,
self.command.as_ref(),
self.argv.as_ref(),
"compact task",
)?;
if (self.command.is_some() || self.argv.is_some()) && self.pty.contains_key("agent") {
anyhow::bail!(
"agent '{identity}' declares both a compact launch and `pty \"agent\"`; choose one form"
);
}
let bus_id = format!("{}.{}", host.as_deref().unwrap_or_default(), identity)
.trim_start_matches('.')
.to_string();
let mut tasks: Vec<Task> = Vec::new();
for (name, t) in self.pty {
tasks.push(t.lower(TaskKind::Pty, name, &self.env));
tasks.push(t.lower(&identity, TaskKind::Pty, name, &self.env)?);
}
for (name, t) in self.exec {
tasks.push(t.lower(TaskKind::Exec, name, &self.env));
tasks.push(t.lower(&identity, TaskKind::Exec, name, &self.env)?);
}
if let Some(command) = self.command {
if self.command.is_some() || self.argv.is_some() {
let mut tags = BTreeMap::new();
tags.insert("role".to_string(), "agent".to_string());
tasks.push(Task {
Expand All @@ -292,7 +311,8 @@ impl RawSpec {
name: "agent".to_string(),
// An agent IS its pty: ding defaults its poke target to this same bus id.
id: Some(bus_id.clone()),
command: Some(command),
command: self.command,
argv: self.argv,
cwd: None,
tags,
env: self.env.clone(),
Expand All @@ -306,6 +326,7 @@ impl RawSpec {
name: "ding".to_string(),
id: Some(format!("{bus_id}.ding")),
command: Some(format!("st2 ding --identity {bus_id} --root $ST_ROOT")),
argv: None,
cwd: None,
tags: BTreeMap::new(),
env: self.env,
Expand All @@ -317,7 +338,7 @@ impl RawSpec {
// `service` is the only job type; a stray `type` string is caught by validate (unknown-type).
let job_type = JobType::Service;

AgentSpec {
Ok(AgentSpec {
identity,
host,
role: self.role,
Expand All @@ -329,31 +350,59 @@ impl RawSpec {
restart: self.restart.map(RawRestart::lower),
tasks,
path,
}
})
}
}

impl RawTask {
pub(crate) fn lower(
self,
identity: &str,
kind: TaskKind,
name: String,
inherited_env: &BTreeMap<String, String>,
) -> Task {
) -> anyhow::Result<Task> {
validate_launch(
identity,
self.command.as_ref(),
self.argv.as_ref(),
&format!("{kind:?} task '{name}'"),
)?;
let mut env = inherited_env.clone();
env.extend(self.env);
Task {
Ok(Task {
kind,
derived: false,
name,
id: self.id,
command: self.command,
argv: self.argv,
cwd: self.cwd,
tags: self.tags,
env,
keep: self.keep,
}
})
}
}

fn validate_launch(
identity: &str,
command: Option<&String>,
argv: Option<&Vec<String>>,
location: &str,
) -> anyhow::Result<()> {
if command.is_some() && argv.is_some() {
anyhow::bail!(
"agent '{identity}' {location} declares both `command` and `argv`; choose one launch form"
);
}
if argv.is_some_and(Vec::is_empty) {
anyhow::bail!("agent '{identity}' {location} declares an empty `argv`");
}
if argv.is_some_and(|argv| argv.first().is_some_and(String::is_empty)) {
anyhow::bail!("agent '{identity}' {location} declares an empty `argv` program");
}
Ok(())
}

#[cfg(test)]
Expand Down
Loading
Loading