Skip to content

Commit ce2c106

Browse files
authored
Merge pull request #86 from compoundingtech/schickling-assistant/2026-07-30-resource-bindings
feat(agent-spec): add typed Resource bindings
2 parents ef20e20 + 0fed14b commit ce2c106

17 files changed

Lines changed: 804 additions & 19 deletions

File tree

INVARIANTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ materialization, messaging, DING, or presence must preserve them.
1414
| **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` |
1515
| **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` |
1616
| **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/compile_agent.rs::compile_agent_generates_claude_then_materializes_verbatim_persona`; `tests/compile_agent.rs::compile_agent_generates_codex_then_materializes_composed_agents_md`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` |
17-
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, explicit retirement state, activity, and inbox counts. Human output marks retired declarations without changing active rows. | `src/agents.rs::agents_json_has_stable_wire_shape`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence` |
17+
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, explicit retirement state, opaque declared Resource descriptors, activity, and inbox counts. Human output marks retired declarations without changing active rows. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence` |
1818
| **Agent-declared presence** | Refresh preserves non-DND declared status and only advances liveness; a missing status starts as `available`, while `dnd` is never refreshed and an unrefreshed declaration ages to `unknown`. | `src/status.rs::refresh_preserves_value_and_bumps_mtime`; `src/status.rs::refresh_leaves_dnd_to_age_out`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::stale_mtime_reads_as_unknown_regardless_of_contents` |
1919
| **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` |
2020
| **Crash loops surface** | A task parked by a fail-mode restart policy notifies its supervisor once over the bus. | `tests/run.rs::surface_crash_loop_notifies_the_supervisor_over_the_bus` |

README.md

Lines changed: 20 additions & 3 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 RFC 3986 absolute URI. `_tag` selects a
128+
concrete resource contract understood by downstream readers; st2 preserves arbitrary non-empty tags
129+
and URI bytes without normalization. It neither owns their schemas nor resolves their targets.
130+
Binding order is irrelevant and names must be unique 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
@@ -254,9 +271,9 @@ st2 context read --full
254271
```
255272

256273
The roster includes retired declarations instead of silently conflating them with runtime
257-
presence. Both JSON shapes contain an additive `retired` boolean; `--enrich` additionally supplies
258-
`lastActivity` and `inbox`. Human output leaves active rows unchanged and appends `[retired]` to a
259-
retired row.
274+
presence. Both JSON shapes contain `retired` and the declaration's ordered `resources` descriptors;
275+
`--enrich` additionally supplies `lastActivity` and `inbox`. Human output leaves active rows
276+
unchanged and appends `[retired]` to a retired row.
260277

261278
For a catalog-backed agent, every native bus operation resolves the same agent directory used by
262279
the roster: presence is `<agent-dir>/status`, while unread messages, archive receipts, context, and

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: 4 additions & 2 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).
@@ -25,4 +25,6 @@ mod kdl_format;
2525
pub mod spec;
2626

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

0 commit comments

Comments
 (0)