Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dcf77b7
feat: separate agent identity from presentation
schickling-assistant Jul 31, 2026
47047de
fix: narrow presentation review scope
schickling-assistant Jul 31, 2026
6880938
fix: preserve compact agent presentation sources
schickling-assistant Jul 31, 2026
2d2cd0d
build: repin reviewed pty metadata patch
schickling-assistant Jul 31, 2026
79b0e48
fix: fail closed on catalog presentation routes
schickling-assistant Jul 31, 2026
6c95a74
Merge remote-tracking branch 'origin/main' into integration/pr136-merge
schickling-assistant Jul 31, 2026
4061a1d
fix: reap metadata child after stdin failure
schickling-assistant Jul 31, 2026
624550c
fix: bound metadata child reaping
schickling-assistant Jul 31, 2026
afb40bb
fix: preserve dotted host identities
schickling-assistant Jul 31, 2026
d758dd1
fix: bound metadata stdin writes
schickling-assistant Jul 31, 2026
47d205f
fix: avoid retained metadata writers
schickling-assistant Jul 31, 2026
d390e40
test: persist escaped pipe identity
schickling-assistant Jul 31, 2026
4ecc13f
test: await escaped reader readiness
schickling-assistant Jul 31, 2026
cf8adbe
test: model an undrained metadata pipe
schickling-assistant Jul 31, 2026
ffffb1f
fix: enforce metadata write deadlines
schickling-assistant Jul 31, 2026
aad301e
fix: preserve literal presentation tags
schickling-assistant Jul 31, 2026
9c4c18e
fix: preserve CRLF presentation sources
schickling-assistant Jul 31, 2026
391a684
fix: suppress lifecycle-equal display names
schickling-assistant Aug 1, 2026
222cab5
fix(eval): preserve external requester routing
schickling-assistant Aug 1, 2026
1cac690
fix: keep lifecycle-equal names suppressed
schickling-assistant Aug 1, 2026
3d00dcd
fix: bound presentation reconciliation work
schickling-assistant Aug 1, 2026
3187b79
fix: close presentation routing fairness gaps
schickling-assistant Aug 1, 2026
70729a3
fix: reject ambiguous canonical routes
schickling-assistant Aug 1, 2026
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
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ agent "<identity>" {
// Optional metadata:
// role "worker"
// supervisor "<supervisor-bus-id>"
// name "Release worker"
// description "Owns release preparation and verification."
env { ST_AGENT "<host>.<identity>" }
argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "<boot prompt>"
ding
Expand Down Expand Up @@ -170,6 +172,24 @@ access, readiness, or lifecycle policy, and URI possession conveys no authority.
declaration edits do not stop, replace, or relaunch a live task. Resource types and resolvers remain
opaque to st2; catalog readers use the public `agent-spec` crate to inspect the typed bindings.

The positional agent value is the stable automation identity. Optional `name` and `description`
fields are presentation only; they never route messages, select tasks, or rename durable state.
Mutate a catalog-owned KDL declaration through the constrained commands:

```sh
st2 rename <stable-id> "Release worker"
st2 describe <stable-id> "Owns release preparation and verification."
st2 rename <stable-id> --clear
```

These commands preserve unrelated KDL bytes and serialize local writers through the persistent
private `.st2/presentation-authoring.lock`. They refuse TOML, JSON, and
explicitly `meta { managed-by "nix" }` targets. Nix generators must emit that marker before the
compatible st2 binary is activated. In the trusted single-operator fleet, caller-supplied
`ST_AGENT` limits an invocation to itself or declared descendants; it is a guardrail rather than
authentication, and absence selects the operator path. The sibling `<agent-dir>/name` convention
is hard-retired and ignored.

`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
Expand Down Expand Up @@ -324,9 +344,10 @@ st2 context read --full
```

The roster includes retired declarations instead of silently conflating them with runtime
presence. Both JSON shapes contain `retired` and the declaration's ordered `resources` descriptors;
`--enrich` additionally supplies `lastActivity` and `inbox`. Human output leaves active rows
unchanged and appends `[retired]` to a retired row.
presence. Both JSON shapes keep stable `identity` separate from optional `name` and `description`,
and contain `retired` plus the declaration's ordered `resources` descriptors. `--enrich`
additionally supplies `lastActivity` and `inbox`. Human output prints the same presentation fields
as separate columns and appends `[retired]` to a retired row.

For a catalog-backed agent, every native bus operation resolves the same agent directory used by
the roster: presence is `<agent-dir>/status`, while unread messages, archive receipts, context, and
Expand Down Expand Up @@ -391,7 +412,7 @@ st2 service uninstall

```text
ls, up, down, validate, doctor
message, ding, agents, status, context, resource
message, ding, agents, status, context, resource, rename, describe
env, pty, shell, pretrust
hooks, service, eval
compile-agent (experimental)
Expand Down
25 changes: 25 additions & 0 deletions crates/agent-spec/src/kdl_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
for child in children.nodes() {
match child.name().value() {
"identity" => raw.identity = arg_string(child).or(raw.identity),
"name" => parse_presentation(child, "name", &mut raw.name)?,
"description" => parse_presentation(child, "description", &mut raw.description)?,
"host" => raw.host = arg_string(child),
"role" => raw.role = arg_string(child),
"type" => raw.job_type = arg_string(child),
Expand Down Expand Up @@ -116,6 +118,29 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
Ok(raw)
}

fn parse_presentation(
node: &KdlNode,
field: &str,
destination: &mut Option<String>,
) -> anyhow::Result<()> {
anyhow::ensure!(
destination.is_none(),
"agent declares `{field}` more than once"
);
anyhow::ensure!(
node.children().is_none()
&& node.entries().len() == 1
&& node.entries()[0].name().is_none(),
"agent `{field}` must contain exactly one positional string"
);
let value = node
.get(0)
.and_then(|value| value.as_string())
.ok_or_else(|| anyhow::anyhow!("agent `{field}` must contain a string"))?;
*destination = Some(value.to_owned());
Ok(())
}

fn resource_node_to_raw(node: &KdlNode) -> anyhow::Result<(String, RawResource)> {
if node.children().is_some() {
anyhow::bail!("resource binding cannot have children");
Expand Down
52 changes: 51 additions & 1 deletion crates/agent-spec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! A job reads like a Nomad job: the *agent* is the job, its **tasks** are `pty{}` (interactive —
//! allocates a terminal, an agent harness) and `exec{}` (a plain process — the ding, daemons, a
//! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset:
//! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`,
//! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only), `type`,
//! `workspace`, `retired`, `keep`, `supervisor`,
//! `restart{}`, task lifecycle, Resource bindings (declaration metadata), and the tasks. Everything render-only
//! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into
//! the tasks/commands by the render layer and ignored here.
Expand All @@ -19,11 +20,20 @@ use std::time::Duration;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Serialize};

/// Maximum Unicode scalar count for an agent's human-facing label.
pub const AGENT_NAME_MAX_CHARS: usize = 160;
/// Maximum Unicode scalar count for an agent's enduring responsibility description.
pub const AGENT_DESCRIPTION_MAX_CHARS: usize = 1_000;

/// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSpec {
/// Unique id; the bus id is `<host>.<identity>`.
pub identity: String,
/// Optional mutable human-facing label. Never used as an automation selector.
pub name: Option<String>,
/// Optional enduring responsibility boundary. Never used for lifecycle decisions.
pub description: Option<String>,
/// Which machine runs this agent. `None` → resolved to the path's host / this machine.
pub host: Option<String>,
/// Optional declared persona role. Preserved as metadata and ignored for execution.
Expand Down Expand Up @@ -264,6 +274,8 @@ pub fn parse_duration(s: &str) -> Result<Duration, String> {
#[derive(Debug, Default, Deserialize)]
pub(crate) struct RawSpec {
pub identity: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub host: Option<String>,
pub role: Option<String>,
#[serde(rename = "type")]
Expand Down Expand Up @@ -600,6 +612,12 @@ impl RawSpec {
host: Option<String>,
path: PathBuf,
) -> anyhow::Result<AgentSpec> {
validate_presentation("name", self.name.as_deref(), AGENT_NAME_MAX_CHARS)?;
validate_presentation(
"description",
self.description.as_deref(),
AGENT_DESCRIPTION_MAX_CHARS,
)?;
validate_launch(
&identity,
self.command.as_ref(),
Expand Down Expand Up @@ -664,6 +682,8 @@ impl RawSpec {

Ok(AgentSpec {
identity,
name: self.name,
description: self.description,
host,
role: self.role,
job_type,
Expand All @@ -679,6 +699,36 @@ impl RawSpec {
}
}

/// Validate one optional presentation field at the shared parse/authoring boundary.
pub fn validate_presentation(
field: &str,
value: Option<&str>,
max_chars: usize,
) -> anyhow::Result<()> {
let Some(value) = value else {
return Ok(());
};
anyhow::ensure!(
!value.is_empty(),
"agent presentation `{field}` cannot be empty; omit it to clear it"
);
anyhow::ensure!(
value.trim() == value,
"agent presentation `{field}` cannot begin or end with whitespace"
);
anyhow::ensure!(
!value.chars().any(|character| {
character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')
}),
"agent presentation `{field}` must be one printable line without control characters or Unicode line separators"
);
anyhow::ensure!(
value.chars().count() <= max_chars,
"agent presentation `{field}` exceeds the {max_chars}-character limit"
);
Ok(())
}

impl RawTask {
pub(crate) fn lower(
self,
Expand Down
142 changes: 142 additions & 0 deletions crates/agent-spec/tests/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,148 @@ argv = ["claude", "--resume", "session id"]
);
}

#[test]
fn presentation_metadata_lowers_from_kdl_toml_and_json_without_changing_identity() {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
"agents/h/kdl/agent.kdl",
r#"agent "kdl" {
host "h"
name "Display label"
description "Enduring responsibility"
command "true"
}"#,
);
write(
tmp.path(),
"agents/h/toml/agent.toml",
r#"identity = "toml"
host = "h"
name = "Display label"
description = "Enduring responsibility"
command = "true"
"#,
);
write(
tmp.path(),
"agents/h/json/agent.json",
r#"{"identity":"json","host":"h","name":"Display label","description":"Enduring responsibility","command":"true"}"#,
);

let found = discover(tmp.path());
assert!(found.errors.is_empty(), "{:?}", found.errors);
for identity in ["kdl", "toml", "json"] {
let spec = find(&found.specs, identity);
assert_eq!(spec.identity, identity);
assert_eq!(spec.name.as_deref(), Some("Display label"));
assert_eq!(spec.description.as_deref(), Some("Enduring responsibility"));
}
}

#[test]
fn malformed_or_duplicate_kdl_presentation_is_rejected() {
for (case, body) in [
("duplicate", "name \"one\"; name \"two\""),
("wrong-type", "description 42"),
("children", "description { nested \"no\" }"),
] {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
&format!("agents/h/{case}/agent.kdl"),
&format!("agent {case:?} {{ host \"h\"; {body}; command \"true\" }}"),
);
let found = discover(tmp.path());
assert!(found.specs.is_empty(), "{case}: {:?}", found.specs);
assert_eq!(found.errors.len(), 1, "{case}: {:?}", found.errors);
assert!(
found.errors[0].message.contains("must contain")
|| found.errors[0].message.contains("more than once"),
"{case}: {}",
found.errors[0].message
);
}
}

#[test]
fn presentation_bounds_count_unicode_scalars_and_reject_noncanonical_values() {
use agent_spec::spec::{
AGENT_DESCRIPTION_MAX_CHARS, AGENT_NAME_MAX_CHARS, validate_presentation,
};

let name_at_limit = "é".repeat(AGENT_NAME_MAX_CHARS);
let description_at_limit = "界".repeat(AGENT_DESCRIPTION_MAX_CHARS);
assert!(validate_presentation("name", Some(&name_at_limit), AGENT_NAME_MAX_CHARS).is_ok());
assert!(
validate_presentation(
"description",
Some(&description_at_limit),
AGENT_DESCRIPTION_MAX_CHARS,
)
.is_ok()
);
assert!(
validate_presentation(
"name",
Some(&format!("{name_at_limit}x")),
AGENT_NAME_MAX_CHARS,
)
.is_err()
);
assert!(
validate_presentation(
"description",
Some(&format!("{description_at_limit}x")),
AGENT_DESCRIPTION_MAX_CHARS,
)
.is_err()
);
for (field, max_chars) in [
("name", AGENT_NAME_MAX_CHARS),
("description", AGENT_DESCRIPTION_MAX_CHARS),
] {
assert!(validate_presentation(field, Some(r"slash/name\path"), max_chars).is_ok());
for invalid in [
"",
" leading",
"trailing ",
"two\nlines",
"control\u{7f}",
"line\u{2028}separator",
"paragraph\u{2029}separator",
] {
assert!(
validate_presentation(field, Some(invalid), max_chars).is_err(),
"accepted {field} {invalid:?}"
);
}
}
}

#[test]
fn presentation_parser_rejects_unicode_line_and_paragraph_separators() {
for field in ["name", "description"] {
for separator in ['\u{2028}', '\u{2029}'] {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
"agents/h/worker/agent.kdl",
&format!(
"agent \"worker\" {{\n host \"h\"\n type \"service\"\n {field} \"left{separator}right\"\n pty \"agent\" {{ command \"true\" }}\n}}\n"
),
);
let found = discover(tmp.path());
assert!(
found.specs.is_empty(),
"accepted {field} U+{:04X}",
separator as u32
);
assert_eq!(found.errors.len(), 1, "{field}: {:?}", found.errors);
}
}
}

#[test]
fn named_resource_bindings_are_typed_uri_identities_and_order_independent() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading