Skip to content

Commit aa8eb48

Browse files
Make catalog delivery facts authoritative (#399)
* feat: make catalog delivery facts authoritative agent-identity: dev3.direct.omp.jmg9x29x agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.11 agent-runtime: OMP 18.0.11 tooling-profile: dotfiles@2161b9c * fix: close catalog graph review gaps agent-identity: dev3.direct.omp.jmg9x29x agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.11 agent-runtime: OMP 18.0.11 tooling-profile: dotfiles@2161b9c * fix: preserve retired inbox on teardown failure agent-identity: dev3.direct.omp.jmg9x29x agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.11 agent-runtime: OMP 18.0.11 tooling-profile: dotfiles@2161b9c * test: align Ding conflict diagnostics agent-identity: dev3.direct.omp.jmg9x29x agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.11 agent-runtime: OMP 18.0.11 tooling-profile: dotfiles@2161b9c * test: admit canonical eval graph fixtures agent-identity: dev3.direct.omp.jmg9x29x agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.11 agent-runtime: OMP 18.0.11 tooling-profile: dotfiles@2161b9c
1 parent e0a155e commit aa8eb48

19 files changed

Lines changed: 1145 additions & 147 deletions

crates/agent-spec/src/kdl_format.rs

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
//! serde-native) and fills a [`RawSpec`], which then flows through the shared identity/host
44
//! resolution. A file may hold more than one `agent` node.
55
//!
6-
//! Only runner-normative fields plus metadata `role` are read; render-only fields (`harness`,
7-
//! `model`, `persona`, `permissions`, `transport`, `strategy`) and the inert `meta{}` block are
8-
//! ignored.
6+
//! Runner-normative fields, managed delivery readiness, and metadata `role` are read. Other
7+
//! render-only fields (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`) and
8+
//! the inert `meta{}` block are ignored.
99
1010
use crate::declared::{DeclaredDocument, DeclaredNode, DeclaredValue};
1111
use crate::spec::{
12-
ClaudeDriver, CodexDriver, OmpDriver, OpenCodeDriver, PiDriver, RawResource, RawRestart,
13-
RawSpec, RawTask,
12+
ClaudeDriver, CodexDriver, DeliveryReadiness, OmpDriver, OpenCodeDriver, PiDriver, RawResource,
13+
RawRestart, RawSpec, RawTask, SessionDriver,
1414
};
1515

1616
/// Lower an already parsed declaration document into the runner's raw representation.
@@ -162,6 +162,13 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
162162
anyhow::anyhow!("agent `session-driver` value must be a string")
163163
})?));
164164
}
165+
"delivery-readiness" => {
166+
anyhow::ensure!(
167+
raw.delivery_readiness.is_none(),
168+
"agent declares `delivery-readiness` more than once"
169+
);
170+
raw.delivery_readiness = Some(delivery_readiness_node_to_raw(child)?);
171+
}
165172
"claude" => {
166173
anyhow::ensure!(
167174
raw.driver.claude.is_none(),
@@ -512,6 +519,68 @@ fn restart_node_to_raw(node: &DeclaredNode) -> RawRestart {
512519
r
513520
}
514521

522+
fn delivery_readiness_node_to_raw(node: &DeclaredNode) -> anyhow::Result<DeliveryReadiness> {
523+
anyhow::ensure!(
524+
node.type_name.is_none() && node.children.is_empty(),
525+
"agent `delivery-readiness` cannot have a type annotation or children"
526+
);
527+
let kind = node
528+
.argument(0)
529+
.and_then(DeclaredValue::as_str)
530+
.ok_or_else(|| anyhow::anyhow!("agent `delivery-readiness` needs a kind string"))?;
531+
match kind {
532+
"credential" => {
533+
anyhow::ensure!(
534+
node.arguments().count() == 1
535+
&& node.properties_named("account-id").count() <= 1
536+
&& node.entries.len() <= 2,
537+
"credential delivery-readiness must be `delivery-readiness \"credential\"` with at most one string `account-id`"
538+
);
539+
let account_id = node
540+
.property("account-id")
541+
.map(|value| {
542+
value.as_str().map(String::from).ok_or_else(|| {
543+
anyhow::anyhow!(
544+
"credential delivery-readiness `account-id` must be a string"
545+
)
546+
})
547+
})
548+
.transpose()?;
549+
Ok(DeliveryReadiness::Credential { account_id })
550+
}
551+
"anonymous" => {
552+
anyhow::ensure!(
553+
node.arguments().count() >= 2
554+
&& node.properties_named("harness").count() == 1
555+
&& node.entries.len() == node.arguments().count() + 1,
556+
"anonymous delivery-readiness must be `delivery-readiness \"anonymous\" \"<model>\"… harness=\"<driver>\"`"
557+
);
558+
let harness = node
559+
.property("harness")
560+
.and_then(DeclaredValue::as_str)
561+
.ok_or_else(|| {
562+
anyhow::anyhow!("anonymous delivery-readiness `harness` must be a string")
563+
})
564+
.and_then(SessionDriver::from_name)?;
565+
let models = node
566+
.arguments()
567+
.skip(1)
568+
.map(|value| {
569+
value.as_str().map(String::from).ok_or_else(|| {
570+
anyhow::anyhow!(
571+
"anonymous delivery-readiness accepts only string model arguments"
572+
)
573+
})
574+
})
575+
.collect::<anyhow::Result<Vec<_>>>()?;
576+
Ok(DeliveryReadiness::Anonymous { harness, models })
577+
}
578+
other => anyhow::bail!(
579+
"unsupported delivery-readiness kind '{other}' (expected `credential` or `anonymous`)"
580+
),
581+
}
582+
}
583+
515584
fn task_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawTask> {
516585
let mut t = RawTask::default();
517586
for child in &node.children {

crates/agent-spec/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ pub use discovery::{
4646
parse_declared, path_defaults,
4747
};
4848
pub use spec::{
49-
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType,
50-
OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, SessionDriver,
51-
Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task,
52-
validate_desired_state_reason,
49+
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryReadiness, DeliveryTransport,
50+
Driver, JobType, OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX,
51+
SessionDriver, Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration,
52+
stream_name_of_task, validate_desired_state_reason,
5353
};
5454
pub use profile::{
5555
DEFAULT_SELECTOR_LIMIT_BYTES, DescriptorValidationError, PROFILE_DESCRIPTOR_ABI_VERSION,

crates/agent-spec/src/spec.rs

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,21 @@ impl DeliveryTransport {
6868
),
6969
}
7070
}
71+
72+
pub fn session_driver(self) -> SessionDriver {
73+
match self {
74+
Self::Mcp => SessionDriver::Claude,
75+
Self::AppServer => SessionDriver::Codex,
76+
Self::PiChannel => SessionDriver::Pi,
77+
}
78+
}
7179
}
7280
/// The native session driver entered by an otherwise opaque launch.
7381
///
7482
/// This is an ownership assertion only. It does not render a provider launch or select a message
7583
/// delivery transport.
76-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85+
#[serde(rename_all = "lowercase")]
7786
pub enum SessionDriver {
7887
Claude,
7988
Codex,
@@ -105,6 +114,71 @@ impl SessionDriver {
105114
),
106115
}
107116
}
117+
118+
/// Parse the canonical driver name carried by `session-driver`.
119+
pub fn from_name(value: &str) -> anyhow::Result<Self> {
120+
Self::parse(value)
121+
}
122+
}
123+
124+
/// Non-secret facts that prove how a managed session can be admitted for delivery.
125+
///
126+
/// This is deliberately separate from activity and runtime health. A credential-backed seat names
127+
/// only its opaque account identifier; an anonymous seat names the exact harness and model allowlist
128+
/// it can launch without credentials.
129+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130+
#[serde(rename_all = "camelCase", tag = "kind", deny_unknown_fields)]
131+
pub enum DeliveryReadiness {
132+
Credential {
133+
account_id: Option<String>,
134+
},
135+
Anonymous {
136+
harness: SessionDriver,
137+
models: Vec<String>,
138+
},
139+
}
140+
141+
impl DeliveryReadiness {
142+
pub fn validate(&mut self) -> anyhow::Result<()> {
143+
match self {
144+
Self::Credential { account_id } => {
145+
if let Some(account_id) = account_id {
146+
validate_delivery_readiness_value("account-id", account_id)?;
147+
}
148+
}
149+
Self::Anonymous { harness: _, models } => {
150+
anyhow::ensure!(
151+
!models.is_empty(),
152+
"anonymous delivery-readiness requires at least one model"
153+
);
154+
anyhow::ensure!(
155+
models.len() <= 32,
156+
"anonymous delivery-readiness accepts at most 32 models"
157+
);
158+
for model in models.iter() {
159+
validate_delivery_readiness_value("model", model)?;
160+
}
161+
models.sort();
162+
models.dedup();
163+
}
164+
}
165+
Ok(())
166+
}
167+
}
168+
169+
fn validate_delivery_readiness_value(field: &str, value: &str) -> anyhow::Result<()> {
170+
anyhow::ensure!(
171+
!value.is_empty() && value.len() <= 200,
172+
"delivery-readiness {field} must be 1..=200 UTF-8 bytes"
173+
);
174+
anyhow::ensure!(
175+
value.trim() == value
176+
&& !value
177+
.chars()
178+
.any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')),
179+
"delivery-readiness {field} must have no surrounding whitespace, controls, or line separators"
180+
);
181+
Ok(())
108182
}
109183

110184

@@ -130,6 +204,16 @@ impl Driver {
130204
Self::Omp(_) => "omp",
131205
}
132206
}
207+
208+
pub fn session_driver(&self) -> SessionDriver {
209+
match self {
210+
Self::Claude(_) => SessionDriver::Claude,
211+
Self::Codex(_) => SessionDriver::Codex,
212+
Self::Pi(_) => SessionDriver::Pi,
213+
Self::OpenCode(_) => SessionDriver::OpenCode,
214+
Self::Omp(_) => SessionDriver::Omp,
215+
}
216+
}
133217
}
134218

135219
/// Typed fields accepted by a `claude {}` driver block.
@@ -258,6 +342,8 @@ pub struct AgentSpec {
258342
pub session_driver: Option<SessionDriver>,
259343
/// Typed harness declaration used by task and render compilation.
260344
pub driver: Option<Driver>,
345+
/// Non-secret admission facts for the managed delivery path.
346+
pub delivery_readiness: Option<DeliveryReadiness>,
261347
/// Named typed references used by the agent. st2 preserves these for readers but does not
262348
/// resolve them or assign launch, readiness, access, or lifecycle semantics.
263349
pub resources: Vec<Resource>,
@@ -270,6 +356,14 @@ pub struct AgentSpec {
270356
pub path: PathBuf,
271357
}
272358

359+
impl AgentSpec {
360+
/// The explicit native session owner after typed-driver normalization.
361+
pub fn effective_session_driver(&self) -> Option<SessionDriver> {
362+
self.session_driver
363+
.or_else(|| self.driver.as_ref().map(Driver::session_driver))
364+
}
365+
}
366+
273367
fn deserialize_optional_selector<'de, D>(
274368
deserializer: D,
275369
) -> Result<Option<serde_json::Value>, D::Error>
@@ -603,6 +697,8 @@ pub(crate) struct RawSpec {
603697
/// Native session ownership asserted for an otherwise opaque launch.
604698
#[serde(default, deserialize_with = "deserialize_explicit_optional")]
605699
pub session_driver: Option<Option<String>>,
700+
/// Non-secret facts used to admit the managed delivery path.
701+
pub delivery_readiness: Option<DeliveryReadiness>,
606702
/// Direct typed provider driver block.
607703
#[serde(flatten)]
608704
pub driver: RawDriver,
@@ -1124,6 +1220,7 @@ impl RawSpec {
11241220
|| self.ding
11251221
|| self.deliver.is_some()
11261222
|| self.session_driver.is_some()
1223+
|| self.delivery_readiness.is_some()
11271224
|| self.driver.claude.is_some()
11281225
|| self.driver.codex.is_some()
11291226
// pi predates this predicate gaining driver awareness and was silently skipped too:
@@ -1171,26 +1268,43 @@ impl RawSpec {
11711268
.transpose()?;
11721269
let driver = self.driver.lower(&identity)?;
11731270
let has_driver = driver.is_some();
1271+
let mut delivery_readiness = self.delivery_readiness;
1272+
if let Some(readiness) = delivery_readiness.as_mut() {
1273+
readiness.validate()?;
1274+
}
11741275
anyhow::ensure!(
11751276
!(self.ding && delivery.is_some()),
11761277
"agent '{identity}' declares both `ding` and `deliver`; choose one transport"
11771278
);
11781279
anyhow::ensure!(
1179-
!(self.ding && has_driver),
1180-
"agent '{identity}' declares both `ding` and a typed driver; choose one session owner"
1280+
!(self.ding && (has_driver || session_driver.is_some() || delivery_readiness.is_some())),
1281+
"agent '{identity}' declares managed native delivery together with `ding`; generic Ding is only for opaque non-harness PTYs"
11811282
);
1182-
if session_driver.is_some() {
1183-
anyhow::ensure!(
1184-
!self.ding,
1185-
"agent '{identity}' declares both `session-driver` and `ding`; choose one session owner"
1186-
);
1283+
anyhow::ensure!(
1284+
!(session_driver.is_some() && has_driver),
1285+
"agent '{identity}' declares both `session-driver` and a typed driver; choose one session owner"
1286+
);
1287+
let effective_session_driver =
1288+
session_driver.or_else(|| driver.as_ref().map(Driver::session_driver));
1289+
if let (Some(delivery), Some(effective)) = (delivery, effective_session_driver) {
11871290
anyhow::ensure!(
1188-
delivery.is_none(),
1189-
"agent '{identity}' declares both `session-driver` and `deliver`; choose one session owner"
1291+
delivery.session_driver() == effective,
1292+
"agent '{identity}' delivery transport '{}' requires session-driver '{}', not '{}'",
1293+
delivery.as_str(),
1294+
delivery.session_driver().as_str(),
1295+
effective.as_str()
11901296
);
1297+
}
1298+
if let (
1299+
Some(DeliveryReadiness::Anonymous { harness, .. }),
1300+
Some(effective),
1301+
) = (delivery_readiness.as_ref(), effective_session_driver)
1302+
{
11911303
anyhow::ensure!(
1192-
!has_driver,
1193-
"agent '{identity}' declares both `session-driver` and a typed driver; choose one session owner"
1304+
*harness == effective,
1305+
"agent '{identity}' anonymous delivery-readiness harness '{}' does not match effective session-driver '{}'",
1306+
harness.as_str(),
1307+
effective.as_str()
11941308
);
11951309
}
11961310
validate_launch(
@@ -1345,6 +1459,7 @@ impl RawSpec {
13451459
delivery,
13461460
session_driver,
13471461
driver,
1462+
delivery_readiness,
13481463
resources,
13491464
streams,
13501465
tasks,

0 commit comments

Comments
 (0)