Skip to content

Commit d29c37f

Browse files
authored
feat(agent-spec): simplify resource references (#307)
* feat(agent-spec): simplify resource references * fix(agent-spec): carry required resource reason through bundled declarations and docs Codex review (P1): requiring reason invalidated the repository's own examples — validate.rs/status_agents.rs fixtures parse-errored into empty rosters, and README/vrs spec still documented a name+uri-only envelope. Add reason to every bundled declaration and update the contract prose to name+uri+reason with optional inactive-reason. * fix(catalog): project resource reason fields into semantic diffs Codex review (P2): normalize_agent projected only /resources/<name>/uri, so edits touching just reason or inactive-reason produced a modified-file marker with no agent semantic field. Emit /reason and optional /inactive-reason alongside /uri so diff consumers can observe explanation and active-state changes.
1 parent 4629aeb commit d29c37f

10 files changed

Lines changed: 154 additions & 170 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ The compact declaration shape is:
187187
agent "<identity>" {
188188
host "<host>"
189189
workspace "<workspace>"
190-
resource "work" uri="github-issue://example/project/123"
190+
resource "work" uri="github-issue://example/project/123" reason="release work item"
191191
// Optional metadata:
192192
// role "worker"
193193
// supervisor "<supervisor-bus-id>"
@@ -250,12 +250,13 @@ It neither registers schemes, owns profile schemas, nor resolves targets.
250250
Binding order is irrelevant and names must be unique within the agent:
251251

252252
```kdl
253-
resource "work" uri="github-issue://example/project/123"
254-
resource "source" uri="worktree://github.com/example/project/change"
255-
resource "delivery" uri="ding://host/agent"
253+
resource "work" uri="github-issue://example/project/123" reason="release work item"
254+
resource "source" uri="worktree://github.com/example/project/change" reason="primary checkout"
255+
resource "delivery" uri="ding://host/agent" reason="notification channel for this agent"
256256
```
257257

258-
The envelope is intentionally only `name` + `uri`. It carries no required/optional,
258+
The envelope is `name` + `uri` + a required human-facing `reason`, plus an optional
259+
`inactive-reason` that preserves a retired binding without deleting it. It carries no
259260
access, readiness, or lifecycle policy, and URI possession conveys no authority. A Resource URI may
260261
be referenced by any number of agent declarations. Resource-only declaration edits do not stop,
261262
replace, or relaunch a live task. Resource profiles and resolvers remain opaque to st2; catalog

crates/agent-spec/src/kdl_format.rs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,8 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou
371371

372372
let mut name = None;
373373
let mut uri = None;
374-
let mut relation = None;
375374
let mut reason = None;
375+
let mut inactive_reason = None;
376376
for entry in &node.entries {
377377
let Some(property) = entry.name.as_deref() else {
378378
if name.is_some() {
@@ -396,15 +396,6 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou
396396
anyhow::bail!("resource binding needs string `uri`");
397397
}
398398
}
399-
"relation" => {
400-
if relation.is_some() {
401-
anyhow::bail!("resource binding has duplicate `relation`");
402-
}
403-
relation = value;
404-
if relation.is_none() {
405-
anyhow::bail!("resource binding needs string `relation`");
406-
}
407-
}
408399
"reason" => {
409400
if reason.is_some() {
410401
anyhow::bail!("resource binding has duplicate `reason`");
@@ -414,6 +405,15 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou
414405
anyhow::bail!("resource binding needs string `reason`");
415406
}
416407
}
408+
"inactive-reason" => {
409+
if inactive_reason.is_some() {
410+
anyhow::bail!("resource binding has duplicate `inactive-reason`");
411+
}
412+
inactive_reason = value;
413+
if inactive_reason.is_none() {
414+
anyhow::bail!("resource binding needs string `inactive-reason`");
415+
}
416+
}
417417
other => anyhow::bail!("resource binding has unsupported property `{other}`"),
418418
}
419419
}
@@ -422,8 +422,9 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou
422422
name.ok_or_else(|| anyhow::anyhow!("resource binding needs a string name"))?,
423423
RawResource {
424424
uri: uri.ok_or_else(|| anyhow::anyhow!("resource binding needs string `uri`"))?,
425-
relation,
426-
reason,
425+
reason: reason
426+
.ok_or_else(|| anyhow::anyhow!("resource binding needs string `reason`"))?,
427+
inactive_reason,
427428
},
428429
))
429430
}

crates/agent-spec/src/spec.rs

Lines changed: 42 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -201,56 +201,55 @@ pub struct AgentSpec {
201201

202202
/// One agent-local semantic binding to an externally identified resource.
203203
///
204-
/// `name` is the role the resource plays for this agent and `uri` is the exact absolute identity.
205-
/// The URI scheme selects the downstream resource profile. The envelope deliberately carries no
206-
/// policy.
204+
/// `name` is an agent-local label and `uri` is the exact absolute identity. `reason` explains why
205+
/// the reference belongs in this Agent Spec. `inactive_reason` preserves a reference that is no
206+
/// longer active for this agent without asserting anything about the resource itself.
207207
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208208
pub struct Resource {
209209
name: String,
210210
uri: String,
211+
reason: String,
211212
#[serde(skip_serializing_if = "Option::is_none")]
212-
relation: Option<String>,
213-
#[serde(skip_serializing_if = "Option::is_none")]
214-
reason: Option<String>,
213+
inactive_reason: Option<String>,
215214
}
216215

217216
#[derive(Deserialize)]
218217
#[serde(deny_unknown_fields)]
219218
struct ResourceDescriptor {
220219
name: String,
221220
uri: String,
222-
relation: Option<String>,
223-
reason: Option<String>,
221+
reason: String,
222+
inactive_reason: Option<String>,
224223
}
225224

226225
impl Resource {
227226
/// Construct a descriptor after enforcing the same invariants as catalog parsing.
228-
pub fn new(name: String, uri: String) -> Result<Self, String> {
227+
pub fn new(name: String, uri: String, reason: String) -> Result<Self, String> {
229228
if name.is_empty() {
230229
return Err("resource binding name cannot be empty".into());
231230
}
232231
validate_absolute_uri(&uri).map_err(|reason| {
233232
format!("resource binding '{name}' `uri` must be an exact absolute URI: {reason}")
234233
})?;
234+
validate_resource_explanation(&name, "reason", &reason)?;
235235
Ok(Self {
236236
name,
237237
uri,
238-
relation: None,
239-
reason: None,
238+
reason,
239+
inactive_reason: None,
240240
})
241241
}
242242

243-
/// Construct a descriptor with an explicit semantic relation and human-facing rationale.
244-
pub fn new_with_relation_reason(
243+
/// Construct a preserved reference that is inactive for this agent.
244+
pub fn new_inactive(
245245
name: String,
246246
uri: String,
247-
relation: String,
248247
reason: String,
248+
inactive_reason: String,
249249
) -> Result<Self, String> {
250-
let mut resource = Self::new(name, uri)?;
251-
validate_relation_reason(&resource.name, &relation, &reason)?;
252-
resource.relation = Some(relation);
253-
resource.reason = Some(reason);
250+
let mut resource = Self::new(name, uri, reason)?;
251+
validate_resource_explanation(&resource.name, "inactive-reason", &inactive_reason)?;
252+
resource.inactive_reason = Some(inactive_reason);
254253
Ok(resource)
255254
}
256255

@@ -262,12 +261,12 @@ impl Resource {
262261
&self.uri
263262
}
264263

265-
pub fn relation(&self) -> Option<&str> {
266-
self.relation.as_deref()
264+
pub fn reason(&self) -> &str {
265+
&self.reason
267266
}
268267

269-
pub fn reason(&self) -> Option<&str> {
270-
self.reason.as_deref()
268+
pub fn inactive_reason(&self) -> Option<&str> {
269+
self.inactive_reason.as_deref()
271270
}
272271
}
273272

@@ -277,19 +276,14 @@ impl<'de> Deserialize<'de> for Resource {
277276
D: serde::Deserializer<'de>,
278277
{
279278
let descriptor = ResourceDescriptor::deserialize(deserializer)?;
280-
let resource = match (descriptor.relation, descriptor.reason) {
281-
(None, None) => Self::new(descriptor.name, descriptor.uri),
282-
(Some(relation), Some(reason)) => {
283-
Self::new_with_relation_reason(descriptor.name, descriptor.uri, relation, reason)
284-
}
285-
(Some(_), None) => Err(format!(
286-
"resource binding '{}' with `relation` must also declare string `reason`",
287-
descriptor.name
288-
)),
289-
(None, Some(_)) => Err(format!(
290-
"resource binding '{}' with `reason` must also declare string `relation`",
291-
descriptor.name
292-
)),
279+
let resource = match descriptor.inactive_reason {
280+
None => Self::new(descriptor.name, descriptor.uri, descriptor.reason),
281+
Some(inactive_reason) => Self::new_inactive(
282+
descriptor.name,
283+
descriptor.uri,
284+
descriptor.reason,
285+
inactive_reason,
286+
),
293287
};
294288
resource.map_err(de::Error::custom)
295289
}
@@ -606,8 +600,8 @@ pub(crate) struct RawResources(BTreeMap<String, RawResource>);
606600
#[serde(deny_unknown_fields)]
607601
pub(crate) struct RawResource {
608602
pub(crate) uri: String,
609-
pub(crate) relation: Option<String>,
610-
pub(crate) reason: Option<String>,
603+
pub(crate) reason: String,
604+
pub(crate) inactive_reason: Option<String>,
611605
}
612606

613607
#[derive(Debug, Default, Deserialize)]
@@ -667,17 +661,11 @@ impl RawResources {
667661
self.0
668662
.into_iter()
669663
.map(|(name, resource)| {
670-
match (resource.relation, resource.reason) {
671-
(None, None) => Resource::new(name, resource.uri),
672-
(Some(relation), Some(reason)) => Resource::new_with_relation_reason(
673-
name, resource.uri, relation, reason,
674-
),
675-
(Some(_), None) => Err(format!(
676-
"resource binding '{name}' with `relation` must also declare string `reason`"
677-
)),
678-
(None, Some(_)) => Err(format!(
679-
"resource binding '{name}' with `reason` must also declare string `relation`"
680-
)),
664+
match resource.inactive_reason {
665+
None => Resource::new(name, resource.uri, resource.reason),
666+
Some(inactive_reason) => {
667+
Resource::new_inactive(name, resource.uri, resource.reason, inactive_reason)
668+
}
681669
}
682670
.map_err(anyhow::Error::msg)
683671
})
@@ -719,35 +707,19 @@ impl<'de> Deserialize<'de> for RawResources {
719707
}
720708
}
721709

722-
fn validate_relation_reason(name: &str, relation: &str, reason: &str) -> Result<(), String> {
723-
let relation_bytes = relation.as_bytes();
724-
let valid_relation = (1..=64).contains(&relation_bytes.len())
725-
&& relation_bytes
726-
.iter()
727-
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
728-
&& relation_bytes
729-
.first()
730-
.is_some_and(u8::is_ascii_alphanumeric)
731-
&& relation_bytes.last().is_some_and(u8::is_ascii_alphanumeric)
732-
&& !relation_bytes.windows(2).any(|pair| pair == b"--");
733-
if !valid_relation {
734-
return Err(format!(
735-
"resource binding '{name}' `relation` must be ASCII kebab-case of 1..64 bytes"
736-
));
737-
}
738-
739-
if reason.is_empty() || reason.len() > 160 {
710+
fn validate_resource_explanation(name: &str, field: &str, value: &str) -> Result<(), String> {
711+
if value.is_empty() || value.len() > 160 {
740712
return Err(format!(
741-
"resource binding '{name}' `reason` must be 1..160 UTF-8 bytes"
713+
"resource binding '{name}' `{field}` must be 1..160 UTF-8 bytes"
742714
));
743715
}
744-
if reason.trim() != reason
745-
|| reason
716+
if value.trim() != value
717+
|| value
746718
.chars()
747719
.any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}'))
748720
{
749721
return Err(format!(
750-
"resource binding '{name}' `reason` must have no surrounding Unicode whitespace, controls, or line separators"
722+
"resource binding '{name}' `{field}` must have no surrounding Unicode whitespace, controls, or line separators"
751723
));
752724
}
753725
Ok(())

0 commit comments

Comments
 (0)