Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
74 changes: 60 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,56 @@ 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`");
}
Ok(())
}

#[cfg(test)]
Expand Down
Loading
Loading