Skip to content

Commit 96a7351

Browse files
authored
Merge pull request #136 from compoundingtech/schickling-assistant/2026-07-31-agent-presentation-main-transplant
Separate stable agent identity from mutable presentation
2 parents 8ca0f36 + 70729a3 commit 96a7351

31 files changed

Lines changed: 3438 additions & 111 deletions

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ agent "<identity>" {
121121
// Optional metadata:
122122
// role "worker"
123123
// supervisor "<supervisor-bus-id>"
124+
// name "Release worker"
125+
// description "Owns release preparation and verification."
124126
env { ST_AGENT "<host>.<identity>" }
125127
argv "codex" "--dangerously-bypass-approvals-and-sandbox" "--dangerously-bypass-hook-trust" "<boot prompt>"
126128
ding
@@ -170,6 +172,24 @@ access, readiness, or lifecycle policy, and URI possession conveys no authority.
170172
declaration edits do not stop, replace, or relaunch a live task. Resource types and resolvers remain
171173
opaque to st2; catalog readers use the public `agent-spec` crate to inspect the typed bindings.
172174

175+
The positional agent value is the stable automation identity. Optional `name` and `description`
176+
fields are presentation only; they never route messages, select tasks, or rename durable state.
177+
Mutate a catalog-owned KDL declaration through the constrained commands:
178+
179+
```sh
180+
st2 rename <stable-id> "Release worker"
181+
st2 describe <stable-id> "Owns release preparation and verification."
182+
st2 rename <stable-id> --clear
183+
```
184+
185+
These commands preserve unrelated KDL bytes and serialize local writers through the persistent
186+
private `.st2/presentation-authoring.lock`. They refuse TOML, JSON, and
187+
explicitly `meta { managed-by "nix" }` targets. Nix generators must emit that marker before the
188+
compatible st2 binary is activated. In the trusted single-operator fleet, caller-supplied
189+
`ST_AGENT` limits an invocation to itself or declared descendants; it is a guardrail rather than
190+
authentication, and absence selects the operator path. The sibling `<agent-dir>/name` convention
191+
is hard-retired and ignored.
192+
173193
`argv` launches its first value directly with the remaining values as arguments. It resolves a bare
174194
program such as `codex` through the task environment's `PATH`, preserves argument boundaries, and
175195
does not introduce a shell. Use `command #"..."#` instead when the task intentionally needs shell
@@ -324,9 +344,10 @@ st2 context read --full
324344
```
325345

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

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

392413
```text
393414
ls, up, down, validate, doctor
394-
message, ding, agents, status, context, resource
415+
message, ding, agents, status, context, resource, rename, describe
395416
env, pty, shell, pretrust
396417
hooks, service, eval
397418
compile-agent (experimental)

crates/agent-spec/src/kdl_format.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
7676
for child in children.nodes() {
7777
match child.name().value() {
7878
"identity" => raw.identity = arg_string(child).or(raw.identity),
79+
"name" => parse_presentation(child, "name", &mut raw.name)?,
80+
"description" => parse_presentation(child, "description", &mut raw.description)?,
7981
"host" => raw.host = arg_string(child),
8082
"role" => raw.role = arg_string(child),
8183
"type" => raw.job_type = arg_string(child),
@@ -116,6 +118,29 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
116118
Ok(raw)
117119
}
118120

121+
fn parse_presentation(
122+
node: &KdlNode,
123+
field: &str,
124+
destination: &mut Option<String>,
125+
) -> anyhow::Result<()> {
126+
anyhow::ensure!(
127+
destination.is_none(),
128+
"agent declares `{field}` more than once"
129+
);
130+
anyhow::ensure!(
131+
node.children().is_none()
132+
&& node.entries().len() == 1
133+
&& node.entries()[0].name().is_none(),
134+
"agent `{field}` must contain exactly one positional string"
135+
);
136+
let value = node
137+
.get(0)
138+
.and_then(|value| value.as_string())
139+
.ok_or_else(|| anyhow::anyhow!("agent `{field}` must contain a string"))?;
140+
*destination = Some(value.to_owned());
141+
Ok(())
142+
}
143+
119144
fn resource_node_to_raw(node: &KdlNode) -> anyhow::Result<(String, RawResource)> {
120145
if node.children().is_some() {
121146
anyhow::bail!("resource binding cannot have children");

crates/agent-spec/src/spec.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
//! A job reads like a Nomad job: the *agent* is the job, its **tasks** are `pty{}` (interactive —
44
//! allocates a terminal, an agent harness) and `exec{}` (a plain process — the ding, daemons, a
55
//! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset:
6-
//! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`,
6+
//! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only), `type`,
7+
//! `workspace`, `retired`, `keep`, `supervisor`,
78
//! `restart{}`, task lifecycle, Resource bindings (declaration metadata), and the tasks. Everything render-only
89
//! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into
910
//! the tasks/commands by the render layer and ignored here.
@@ -19,11 +20,20 @@ use std::time::Duration;
1920
use serde::de::{self, MapAccess, Visitor};
2021
use serde::{Deserialize, Serialize};
2122

23+
/// Maximum Unicode scalar count for an agent's human-facing label.
24+
pub const AGENT_NAME_MAX_CHARS: usize = 160;
25+
/// Maximum Unicode scalar count for an agent's enduring responsibility description.
26+
pub const AGENT_DESCRIPTION_MAX_CHARS: usize = 1_000;
27+
2228
/// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect.
2329
#[derive(Debug, Clone, PartialEq, Eq)]
2430
pub struct AgentSpec {
2531
/// Unique id; the bus id is `<host>.<identity>`.
2632
pub identity: String,
33+
/// Optional mutable human-facing label. Never used as an automation selector.
34+
pub name: Option<String>,
35+
/// Optional enduring responsibility boundary. Never used for lifecycle decisions.
36+
pub description: Option<String>,
2737
/// Which machine runs this agent. `None` → resolved to the path's host / this machine.
2838
pub host: Option<String>,
2939
/// Optional declared persona role. Preserved as metadata and ignored for execution.
@@ -264,6 +274,8 @@ pub fn parse_duration(s: &str) -> Result<Duration, String> {
264274
#[derive(Debug, Default, Deserialize)]
265275
pub(crate) struct RawSpec {
266276
pub identity: Option<String>,
277+
pub name: Option<String>,
278+
pub description: Option<String>,
267279
pub host: Option<String>,
268280
pub role: Option<String>,
269281
#[serde(rename = "type")]
@@ -600,6 +612,12 @@ impl RawSpec {
600612
host: Option<String>,
601613
path: PathBuf,
602614
) -> anyhow::Result<AgentSpec> {
615+
validate_presentation("name", self.name.as_deref(), AGENT_NAME_MAX_CHARS)?;
616+
validate_presentation(
617+
"description",
618+
self.description.as_deref(),
619+
AGENT_DESCRIPTION_MAX_CHARS,
620+
)?;
603621
validate_launch(
604622
&identity,
605623
self.command.as_ref(),
@@ -664,6 +682,8 @@ impl RawSpec {
664682

665683
Ok(AgentSpec {
666684
identity,
685+
name: self.name,
686+
description: self.description,
667687
host,
668688
role: self.role,
669689
job_type,
@@ -679,6 +699,36 @@ impl RawSpec {
679699
}
680700
}
681701

702+
/// Validate one optional presentation field at the shared parse/authoring boundary.
703+
pub fn validate_presentation(
704+
field: &str,
705+
value: Option<&str>,
706+
max_chars: usize,
707+
) -> anyhow::Result<()> {
708+
let Some(value) = value else {
709+
return Ok(());
710+
};
711+
anyhow::ensure!(
712+
!value.is_empty(),
713+
"agent presentation `{field}` cannot be empty; omit it to clear it"
714+
);
715+
anyhow::ensure!(
716+
value.trim() == value,
717+
"agent presentation `{field}` cannot begin or end with whitespace"
718+
);
719+
anyhow::ensure!(
720+
!value.chars().any(|character| {
721+
character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')
722+
}),
723+
"agent presentation `{field}` must be one printable line without control characters or Unicode line separators"
724+
);
725+
anyhow::ensure!(
726+
value.chars().count() <= max_chars,
727+
"agent presentation `{field}` exceeds the {max_chars}-character limit"
728+
);
729+
Ok(())
730+
}
731+
682732
impl RawTask {
683733
pub(crate) fn lower(
684734
self,

crates/agent-spec/tests/discovery.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,148 @@ argv = ["claude", "--resume", "session id"]
306306
);
307307
}
308308

309+
#[test]
310+
fn presentation_metadata_lowers_from_kdl_toml_and_json_without_changing_identity() {
311+
let tmp = tempfile::tempdir().unwrap();
312+
write(
313+
tmp.path(),
314+
"agents/h/kdl/agent.kdl",
315+
r#"agent "kdl" {
316+
host "h"
317+
name "Display label"
318+
description "Enduring responsibility"
319+
command "true"
320+
}"#,
321+
);
322+
write(
323+
tmp.path(),
324+
"agents/h/toml/agent.toml",
325+
r#"identity = "toml"
326+
host = "h"
327+
name = "Display label"
328+
description = "Enduring responsibility"
329+
command = "true"
330+
"#,
331+
);
332+
write(
333+
tmp.path(),
334+
"agents/h/json/agent.json",
335+
r#"{"identity":"json","host":"h","name":"Display label","description":"Enduring responsibility","command":"true"}"#,
336+
);
337+
338+
let found = discover(tmp.path());
339+
assert!(found.errors.is_empty(), "{:?}", found.errors);
340+
for identity in ["kdl", "toml", "json"] {
341+
let spec = find(&found.specs, identity);
342+
assert_eq!(spec.identity, identity);
343+
assert_eq!(spec.name.as_deref(), Some("Display label"));
344+
assert_eq!(spec.description.as_deref(), Some("Enduring responsibility"));
345+
}
346+
}
347+
348+
#[test]
349+
fn malformed_or_duplicate_kdl_presentation_is_rejected() {
350+
for (case, body) in [
351+
("duplicate", "name \"one\"; name \"two\""),
352+
("wrong-type", "description 42"),
353+
("children", "description { nested \"no\" }"),
354+
] {
355+
let tmp = tempfile::tempdir().unwrap();
356+
write(
357+
tmp.path(),
358+
&format!("agents/h/{case}/agent.kdl"),
359+
&format!("agent {case:?} {{ host \"h\"; {body}; command \"true\" }}"),
360+
);
361+
let found = discover(tmp.path());
362+
assert!(found.specs.is_empty(), "{case}: {:?}", found.specs);
363+
assert_eq!(found.errors.len(), 1, "{case}: {:?}", found.errors);
364+
assert!(
365+
found.errors[0].message.contains("must contain")
366+
|| found.errors[0].message.contains("more than once"),
367+
"{case}: {}",
368+
found.errors[0].message
369+
);
370+
}
371+
}
372+
373+
#[test]
374+
fn presentation_bounds_count_unicode_scalars_and_reject_noncanonical_values() {
375+
use agent_spec::spec::{
376+
AGENT_DESCRIPTION_MAX_CHARS, AGENT_NAME_MAX_CHARS, validate_presentation,
377+
};
378+
379+
let name_at_limit = "é".repeat(AGENT_NAME_MAX_CHARS);
380+
let description_at_limit = "界".repeat(AGENT_DESCRIPTION_MAX_CHARS);
381+
assert!(validate_presentation("name", Some(&name_at_limit), AGENT_NAME_MAX_CHARS).is_ok());
382+
assert!(
383+
validate_presentation(
384+
"description",
385+
Some(&description_at_limit),
386+
AGENT_DESCRIPTION_MAX_CHARS,
387+
)
388+
.is_ok()
389+
);
390+
assert!(
391+
validate_presentation(
392+
"name",
393+
Some(&format!("{name_at_limit}x")),
394+
AGENT_NAME_MAX_CHARS,
395+
)
396+
.is_err()
397+
);
398+
assert!(
399+
validate_presentation(
400+
"description",
401+
Some(&format!("{description_at_limit}x")),
402+
AGENT_DESCRIPTION_MAX_CHARS,
403+
)
404+
.is_err()
405+
);
406+
for (field, max_chars) in [
407+
("name", AGENT_NAME_MAX_CHARS),
408+
("description", AGENT_DESCRIPTION_MAX_CHARS),
409+
] {
410+
assert!(validate_presentation(field, Some(r"slash/name\path"), max_chars).is_ok());
411+
for invalid in [
412+
"",
413+
" leading",
414+
"trailing ",
415+
"two\nlines",
416+
"control\u{7f}",
417+
"line\u{2028}separator",
418+
"paragraph\u{2029}separator",
419+
] {
420+
assert!(
421+
validate_presentation(field, Some(invalid), max_chars).is_err(),
422+
"accepted {field} {invalid:?}"
423+
);
424+
}
425+
}
426+
}
427+
428+
#[test]
429+
fn presentation_parser_rejects_unicode_line_and_paragraph_separators() {
430+
for field in ["name", "description"] {
431+
for separator in ['\u{2028}', '\u{2029}'] {
432+
let tmp = tempfile::tempdir().unwrap();
433+
write(
434+
tmp.path(),
435+
"agents/h/worker/agent.kdl",
436+
&format!(
437+
"agent \"worker\" {{\n host \"h\"\n type \"service\"\n {field} \"left{separator}right\"\n pty \"agent\" {{ command \"true\" }}\n}}\n"
438+
),
439+
);
440+
let found = discover(tmp.path());
441+
assert!(
442+
found.specs.is_empty(),
443+
"accepted {field} U+{:04X}",
444+
separator as u32
445+
);
446+
assert_eq!(found.errors.len(), 1, "{field}: {:?}", found.errors);
447+
}
448+
}
449+
}
450+
309451
#[test]
310452
fn named_resource_bindings_are_typed_uri_identities_and_order_independent() {
311453
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)