Skip to content

Commit f6e560b

Browse files
committed
fix(catalog): preserve colocated agent declarations
1 parent 4c493f3 commit f6e560b

3 files changed

Lines changed: 67 additions & 3 deletions

File tree

crates/agent-spec/src/discovery.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
use std::fs;
1111
use std::path::{Component, Path, PathBuf};
1212

13-
use crate::declared::{DeclaredParse, parse_declared_document};
13+
use crate::declared::{
14+
DeclaredDiagnosticCode, DeclaredParse, parse_declared_document,
15+
};
1416
use crate::spec::{AgentSpec, RawSpec};
1517

1618
/// The result of walking a catalog folder. Sorted + deterministic.
@@ -361,7 +363,8 @@ fn parse_raw_file_with_declaration(path: &Path) -> ParsedRawFile {
361363
}
362364
};
363365
if ext == "kdl" {
364-
let declaration = parse_declared_document(path, &text);
366+
let mut declaration = parse_declared_document(path, &text);
367+
admit_catalog_envelope_nodes(path, &mut declaration);
365368
let is_adjacent_kdl = declaration
366369
.document
367370
.as_ref()
@@ -411,6 +414,29 @@ fn parse_raw_file_with_declaration(path: &Path) -> ParsedRawFile {
411414
}
412415
}
413416

417+
/// `catalog.kdl` is a shared envelope: st2 owns its `catalog`/`profile` nodes while Agent Spec
418+
/// discovery owns any colocated `agent` nodes. Suppress only the top-level diagnostics attached to
419+
/// those two explicitly admitted envelope node kinds; every agent-shape diagnostic and every other
420+
/// unexpected node remains an error.
421+
fn admit_catalog_envelope_nodes(path: &Path, declaration: &mut DeclaredParse) {
422+
if path.file_name().and_then(|name| name.to_str()) != Some("catalog.kdl") {
423+
return;
424+
}
425+
let Some(document) = declaration.document.as_ref() else {
426+
return;
427+
};
428+
let admitted_spans = document
429+
.nodes
430+
.iter()
431+
.filter(|node| matches!(node.name.as_str(), "catalog" | "profile"))
432+
.map(|node| node.span)
433+
.collect::<Vec<_>>();
434+
declaration.diagnostics.retain(|diagnostic| {
435+
diagnostic.code != DeclaredDiagnosticCode::UnexpectedTopLevelNode
436+
|| !admitted_spans.contains(&diagnostic.span)
437+
});
438+
}
439+
414440
/// Parse one file into `(specs, warnings)`. TOML/JSON yield 0-or-1 spec; KDL yields one per `agent`
415441
/// node. Non-spec files yield an empty vec; a malformed file is an `Err` (collected, never fatal).
416442
fn load_specs(

src/resync.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1351,7 +1351,7 @@ mod tests {
13511351
let current_path = resources.join("current-goal.md");
13521352
std::fs::write(&old_path, "pending bytes").unwrap();
13531353
std::fs::write(&current_path, "current rebound bytes").unwrap();
1354-
let current = watch_set_for(&discover(root.path()), "alias");
1354+
let current = watch_set_for(&discover(root.path()), "alias", &Default::default());
13551355
let declaration = current.declaration_path.clone();
13561356
let mut worker = Worker {
13571357
root: root.path().to_path_buf(),
@@ -1364,6 +1364,7 @@ mod tests {
13641364
seat_id: None,
13651365
label: "goal".to_owned(),
13661366
class: CarrierClass::Immediate,
1367+
containment_root: None,
13671368
digest: Some("old-digest".to_owned()),
13681369
pending_transition: None,
13691370
dirty: true,
@@ -1428,6 +1429,7 @@ mod tests {
14281429
seat_id: None,
14291430
label: "goal".to_owned(),
14301431
class: CarrierClass::Immediate,
1432+
containment_root: None,
14311433
digest: read_digest(&old_path, None),
14321434
pending_transition: None,
14331435
dirty: false,
@@ -1446,6 +1448,7 @@ mod tests {
14461448
label: "goal".to_owned(),
14471449
path: new_path.clone(),
14481450
class: CarrierClass::Immediate,
1451+
containment_root: None,
14491452
}],
14501453
}]));
14511454

@@ -1475,6 +1478,7 @@ mod tests {
14751478
seat_id: None,
14761479
label: "spec".to_owned(),
14771480
class: CarrierClass::Immediate,
1481+
containment_root: None,
14781482
digest: read_digest(&carrier, None),
14791483
pending_transition: None,
14801484
dirty: true,
@@ -1493,6 +1497,7 @@ mod tests {
14931497
label: "spec".to_owned(),
14941498
path: carrier.clone(),
14951499
class,
1500+
containment_root: None,
14961501
}],
14971502
}])
14981503
};

tests/catalog_config.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ fn declaring_a_root_does_not_turn_the_catalog_into_a_single_file_spec() {
117117
);
118118
}
119119

120+
#[test]
121+
fn catalog_profile_and_agent_nodes_share_catalog_kdl_without_hiding_the_agent() {
122+
let catalog = tempfile::tempdir().unwrap();
123+
fs::write(
124+
catalog.path().join("catalog.kdl"),
125+
r#"catalog { pty-root "pty" }
126+
profile "dev.example.goal" { wasm "resolver.wasm"; class "immediate" }
127+
agent "worker" { host "hetz"; command "true" }
128+
"#,
129+
)
130+
.unwrap();
131+
132+
let discovered = st2::discover_strict(catalog.path());
133+
assert!(discovered.errors.is_empty(), "{:?}", discovered.errors);
134+
assert!(discovered.warnings.is_empty(), "{:?}", discovered.warnings);
135+
assert_eq!(discovered.specs.len(), 1);
136+
assert_eq!(discovered.specs[0].identity, "worker");
137+
assert_eq!(discovered.specs[0].host.as_deref(), Some("hetz"));
138+
assert!(
139+
discovered.declarations[0]
140+
.parse
141+
.as_ref()
142+
.is_some_and(agent_spec::DeclaredParse::is_valid),
143+
"the shared catalog envelope must retain a valid agent declaration parse"
144+
);
145+
146+
let config = st2::catalog::load(catalog.path()).expect("catalog and profile nodes parse");
147+
assert_eq!(config.pty_root.as_deref(), Some("pty"));
148+
assert_eq!(config.profiles.len(), 1);
149+
assert_eq!(config.profiles[0].scheme, "dev.example.goal");
150+
assert_eq!(config.profiles[0].wasm, "resolver.wasm");
151+
}
152+
120153
/// A mistyped field resolves back to `<catalog>/pty`, which is the split registry this declaration
121154
/// exists to prevent — so it fails the gate instead of degrading quietly.
122155
#[test]

0 commit comments

Comments
 (0)