Skip to content

Commit bde2cf3

Browse files
feat(agent-spec): add typed resource bindings
Define a minimal named Resource envelope with exact URI identity, opaque type tags, strict uniqueness and shape validation, and no runtime lifecycle semantics. agent-session-id: dev3.dotfiles-cos-misc-st2-resource-design agent-tool: Codex agent-tool-version: 0.145.0 agent-model: gpt-5.6-sol agent-runtime-profile: /home/schickling/.local/state/agent-session-recovery/2026-07-29-pty-st2-cutover/runtime-profile/profile-without-null-opencode.json agent-skills-manifest: /nix/store/kx5j47nghj1yps2v693ryb6wnf1c2xhb-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@16a22c6
1 parent 661c88b commit bde2cf3

12 files changed

Lines changed: 476 additions & 12 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ The compact declaration shape is:
106106
agent "<identity>" {
107107
host "<host>"
108108
workspace "<workspace>"
109+
resource "work" _tag="github-issue" uri="github-issue://example/project/123"
109110
// Optional metadata:
110111
// role "worker"
111112
// supervisor "<supervisor-bus-id>"
@@ -123,6 +124,22 @@ agent "<identity>" {
123124
}
124125
```
125126

127+
`resource` binds an agent-local semantic name to an exact absolute URI. `_tag` selects a concrete
128+
resource contract understood by downstream readers; st2 preserves arbitrary non-empty tags without
129+
owning their schemas or resolving their URIs. Binding order is irrelevant and names must be unique
130+
within the agent:
131+
132+
```kdl
133+
resource "work" _tag="github-issue" uri="github-issue://example/project/123"
134+
resource "source" _tag="worktree" uri="worktree://github.com/example/project/change"
135+
resource "delivery" _tag="ding" uri="ding://host/agent"
136+
```
137+
138+
The envelope is intentionally only `name` + `_tag` + `uri`. It carries no required/optional,
139+
access, readiness, or lifecycle policy, and URI possession conveys no authority. Resource-only
140+
declaration edits do not stop, replace, or relaunch a live task. Resource types and resolvers remain
141+
opaque to st2; catalog readers use the public `agent-spec` crate to inspect the typed bindings.
142+
126143
`argv` launches its first value directly with the remaining values as arguments. It resolves a bare
127144
program such as `codex` through the task environment's `PATH`, preserves argument boundaries, and
128145
does not introduce a shell. Use `command #"..."#` instead when the task intentionally needs shell

crates/agent-spec/src/kdl_format.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
use kdl::{KdlDocument, KdlNode};
1111

12-
use crate::spec::{RawRestart, RawSpec, RawTask};
12+
use crate::spec::{RawResource, RawRestart, RawSpec, RawTask};
1313

1414
/// Parse a KDL document into zero or more raw specs (one per top-level `agent` node).
1515
pub(crate) fn parse_kdl(text: &str) -> anyhow::Result<Vec<RawSpec>> {
@@ -84,6 +84,10 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
8484
"retired" => raw.retired = arg_bool(child),
8585
"keep" => raw.keep = arg_bool(child),
8686
"restart" => raw.restart = Some(restart_node_to_raw(child)),
87+
"resource" => {
88+
let (name, resource) = resource_node_to_raw(child)?;
89+
raw.resource.insert(name, resource)?;
90+
}
8791
"command" => raw.command = arg_string(child),
8892
"argv" => raw.argv = Some(argv(child)?),
8993
"ding" => raw.ding = true,
@@ -111,6 +115,60 @@ fn agent_node_to_raw(node: &KdlNode) -> anyhow::Result<RawSpec> {
111115
Ok(raw)
112116
}
113117

118+
fn resource_node_to_raw(node: &KdlNode) -> anyhow::Result<(String, RawResource)> {
119+
if node.children().is_some() {
120+
anyhow::bail!("resource binding cannot have children");
121+
}
122+
123+
let mut name = None;
124+
let mut tag = None;
125+
let mut uri = None;
126+
for entry in node.entries() {
127+
let Some(property) = entry.name() else {
128+
if name.is_some() {
129+
anyhow::bail!("resource binding accepts exactly one positional name");
130+
}
131+
name = entry.value().as_string().map(String::from);
132+
if name.is_none() {
133+
anyhow::bail!("resource binding needs a string name");
134+
}
135+
continue;
136+
};
137+
138+
let property = property.value();
139+
let value = entry.value().as_string().map(String::from);
140+
match property {
141+
"_tag" => {
142+
if tag.is_some() {
143+
anyhow::bail!("resource binding has duplicate `_tag`");
144+
}
145+
tag = value;
146+
if tag.is_none() {
147+
anyhow::bail!("resource binding needs string `_tag`");
148+
}
149+
}
150+
"uri" => {
151+
if uri.is_some() {
152+
anyhow::bail!("resource binding has duplicate `uri`");
153+
}
154+
uri = value;
155+
if uri.is_none() {
156+
anyhow::bail!("resource binding needs string `uri`");
157+
}
158+
}
159+
other => anyhow::bail!("resource binding has unsupported property `{other}`"),
160+
}
161+
}
162+
163+
Ok((
164+
name.ok_or_else(|| anyhow::anyhow!("resource binding needs a string name"))?,
165+
RawResource {
166+
tag: tag.ok_or_else(|| anyhow::anyhow!("resource binding needs string `_tag`"))?,
167+
uri: uri.ok_or_else(|| anyhow::anyhow!("resource binding needs string `uri`"))?,
168+
},
169+
))
170+
}
171+
114172
fn restart_node_to_raw(node: &KdlNode) -> RawRestart {
115173
let mut r = RawRestart::default();
116174
let Some(children) = node.children() else {

crates/agent-spec/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! One agent is one declarative file, Nomad-style: the agent is the job and its `pty`/`exec` blocks
44
//! are the tasks. This crate owns the two halves any reader of that catalog needs and nothing else:
55
//!
6-
//! - [`spec`] — the runner-normative model a declaration lowers to ([`AgentSpec`], [`Task`], …).
6+
//! - [`spec`] — the shared model a declaration lowers to ([`AgentSpec`], [`Task`], [`Resource`], …).
77
//! - [`discovery`] — the catalog walk: parse every `*.{kdl,toml,json}` that looks like a
88
//! declaration, and resolve each one's `identity`/`host` with the catalog's precedence rule
99
//! (content wins, the path supplies defaults, a mismatch is a warning).
@@ -14,7 +14,8 @@
1414
//!
1515
//! st2 consumes this crate, which is what keeps it a reference implementation rather than a copy:
1616
//! a second reader (a TUI, a linter) sees exactly the fields the runner sees, including the ones
17-
//! the runner's roster JSON does not carry (`supervisor`, `role`, `workspace`, `host`).
17+
//! the runner's roster JSON does not carry (`supervisor`, `role`, `workspace`, `host`, Resource
18+
//! bindings).
1819
//!
1920
//! Render-only fields (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`,
2021
//! `meta{}`) are read by the render layer and deliberately dropped here — that is what keeps a
@@ -25,4 +26,6 @@ mod kdl_format;
2526
pub mod spec;
2627

2728
pub use discovery::{Declared, Discovered, SpecError, discover, parse_declared, path_defaults};
28-
pub use spec::{AgentSpec, JobType, Restart, RestartMode, Task, TaskKind, parse_duration};
29+
pub use spec::{
30+
AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, parse_duration,
31+
};

crates/agent-spec/src/spec.rs

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
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:
66
//! `identity`, `host`, `role` (metadata only), `type`, `workspace`, `retired`, `keep`, `supervisor`,
7-
//! `restart{}`, and the tasks. Everything render-only (`harness`, `model`, `persona`, `permissions`,
8-
//! `transport`, `strategy`, `meta{}`) is baked into the tasks/commands by the render layer and
9-
//! ignored here.
7+
//! `restart{}`, Resource bindings (declaration metadata), and the tasks. Everything render-only
8+
//! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into
9+
//! the tasks/commands by the render layer and ignored here.
1010
//!
1111
//! Three on-disk formats lower to this model: KDL (canonical, parsed by hand in `kdl_format`), and
1212
//! TOML/JSON (serde). Every spec is a `service` — `type = batch` is retired; evals run through the
@@ -16,9 +16,10 @@ use std::collections::BTreeMap;
1616
use std::path::PathBuf;
1717
use std::time::Duration;
1818

19-
use serde::Deserialize;
19+
use serde::de::{self, MapAccess, Visitor};
20+
use serde::{Deserialize, Serialize};
2021

21-
/// A rendered agent job, lowered to the fields st2 needs to run it.
22+
/// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect.
2223
#[derive(Debug, Clone, PartialEq, Eq)]
2324
pub struct AgentSpec {
2425
/// Unique id; the bus id is `<host>.<identity>`.
@@ -39,12 +40,27 @@ pub struct AgentSpec {
3940
pub keep: bool,
4041
/// Crash/restart policy (§4). `None` → the runner's default policy.
4142
pub restart: Option<Restart>,
43+
/// Named typed references used by the agent. st2 preserves these for readers but does not
44+
/// resolve them or assign launch, readiness, access, or lifecycle semantics.
45+
pub resources: Vec<Resource>,
4246
/// The runnable tasks (`pty` + `exec`), sorted by name for determinism.
4347
pub tasks: Vec<Task>,
4448
/// Where this spec was loaded from — the anchor for its resources and for edits.
4549
pub path: PathBuf,
4650
}
4751

52+
/// One agent-local semantic binding to an externally identified resource.
53+
///
54+
/// `name` is the role the resource plays for this agent, `tag` selects the downstream resource
55+
/// contract, and `uri` is the exact absolute identity. The envelope deliberately carries no policy.
56+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57+
pub struct Resource {
58+
pub name: String,
59+
#[serde(rename = "_tag")]
60+
pub tag: String,
61+
pub uri: String,
62+
}
63+
4864
/// The kind of job. Only `service` (long-running) remains — `type = batch` is retired; the native
4965
/// `st2 eval` path (eval_spec/eval_run) replaces the old staged batch executor.
5066
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -199,6 +215,10 @@ pub(crate) struct RawSpec {
199215
#[serde(default)]
200216
pub keep: bool,
201217
pub restart: Option<RawRestart>,
218+
/// Named resource bindings. Singular `resource` matches canonical KDL and keeps TOML/JSON maps
219+
/// aligned with `resource "<name>"`.
220+
#[serde(default)]
221+
pub resource: RawResources,
202222
/// Compact catalog form: the agent itself is one pty carrying this command.
203223
pub command: Option<String>,
204224
/// Compact catalog form: the agent itself is one pty launched directly with this argv.
@@ -217,6 +237,17 @@ pub(crate) struct RawSpec {
217237
pub exec: BTreeMap<String, RawTask>,
218238
}
219239

240+
#[derive(Debug, Default)]
241+
pub(crate) struct RawResources(BTreeMap<String, RawResource>);
242+
243+
#[derive(Debug, Deserialize)]
244+
#[serde(deny_unknown_fields)]
245+
pub(crate) struct RawResource {
246+
#[serde(rename = "_tag")]
247+
pub(crate) tag: String,
248+
pub(crate) uri: String,
249+
}
250+
220251
#[derive(Debug, Default, Deserialize)]
221252
pub(crate) struct RawTask {
222253
pub id: Option<String>,
@@ -261,6 +292,113 @@ impl RawRestart {
261292
}
262293
}
263294

295+
impl RawResources {
296+
pub(crate) fn insert(&mut self, name: String, resource: RawResource) -> anyhow::Result<()> {
297+
if self.0.insert(name.clone(), resource).is_some() {
298+
anyhow::bail!("duplicate resource binding '{name}'");
299+
}
300+
Ok(())
301+
}
302+
303+
fn lower(self) -> anyhow::Result<Vec<Resource>> {
304+
self.0
305+
.into_iter()
306+
.map(|(name, resource)| {
307+
if name.is_empty() {
308+
anyhow::bail!("resource binding name cannot be empty");
309+
}
310+
if resource.tag.is_empty() {
311+
anyhow::bail!("resource binding '{name}' has an empty `_tag`");
312+
}
313+
validate_absolute_uri(&resource.uri).map_err(|reason| {
314+
anyhow::anyhow!(
315+
"resource binding '{name}' `uri` must be an exact absolute URI: {reason}"
316+
)
317+
})?;
318+
Ok(Resource {
319+
name,
320+
tag: resource.tag,
321+
uri: resource.uri,
322+
})
323+
})
324+
.collect()
325+
}
326+
}
327+
328+
impl<'de> Deserialize<'de> for RawResources {
329+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
330+
where
331+
D: serde::Deserializer<'de>,
332+
{
333+
struct ResourceMapVisitor;
334+
335+
impl<'de> Visitor<'de> for ResourceMapVisitor {
336+
type Value = RawResources;
337+
338+
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339+
formatter.write_str("a map of uniquely named resource bindings")
340+
}
341+
342+
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
343+
where
344+
A: MapAccess<'de>,
345+
{
346+
let mut resources = BTreeMap::new();
347+
while let Some((name, resource)) = map.next_entry::<String, RawResource>()? {
348+
if resources.insert(name.clone(), resource).is_some() {
349+
return Err(de::Error::custom(format!(
350+
"duplicate resource binding '{name}'"
351+
)));
352+
}
353+
}
354+
Ok(RawResources(resources))
355+
}
356+
}
357+
358+
deserializer.deserialize_map(ResourceMapVisitor)
359+
}
360+
}
361+
362+
fn validate_absolute_uri(uri: &str) -> Result<(), &'static str> {
363+
let Some(colon) = uri.find(':') else {
364+
return Err("missing scheme");
365+
};
366+
let scheme = &uri[..colon];
367+
let mut chars = scheme.chars();
368+
if !chars
369+
.next()
370+
.is_some_and(|character| character.is_ascii_alphabetic())
371+
|| !chars.all(|character| {
372+
character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
373+
})
374+
{
375+
return Err("invalid scheme");
376+
}
377+
if !uri.is_ascii()
378+
|| uri
379+
.bytes()
380+
.any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
381+
{
382+
return Err("contains characters outside URI syntax");
383+
}
384+
let bytes = uri.as_bytes();
385+
let mut offset = colon + 1;
386+
while offset < bytes.len() {
387+
if bytes[offset] == b'%' {
388+
if offset + 2 >= bytes.len()
389+
|| !bytes[offset + 1].is_ascii_hexdigit()
390+
|| !bytes[offset + 2].is_ascii_hexdigit()
391+
{
392+
return Err("contains an invalid percent escape");
393+
}
394+
offset += 3;
395+
} else {
396+
offset += 1;
397+
}
398+
}
399+
Ok(())
400+
}
401+
264402
impl RawSpec {
265403
/// A parsed file is a *spec candidate* when it carries an agent-shaped signal — an identity, a
266404
/// `type`, or task blocks. Random TOML/JSON in the tree has none of these and is skipped.
@@ -270,6 +408,7 @@ impl RawSpec {
270408
|| self.command.is_some()
271409
|| self.argv.is_some()
272410
|| self.ding
411+
|| !self.resource.0.is_empty()
273412
|| !self.pty.is_empty()
274413
|| !self.exec.is_empty()
275414
}
@@ -337,6 +476,7 @@ impl RawSpec {
337476

338477
// `service` is the only job type; a stray `type` string is caught by validate (unknown-type).
339478
let job_type = JobType::Service;
479+
let resources = self.resource.lower()?;
340480

341481
Ok(AgentSpec {
342482
identity,
@@ -348,6 +488,7 @@ impl RawSpec {
348488
retired: self.retired,
349489
keep: self.keep,
350490
restart: self.restart.map(RawRestart::lower),
491+
resources,
351492
tasks,
352493
path,
353494
})

0 commit comments

Comments
 (0)