Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions crates/agent-spec/src/kdl_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
//! serde-native) and fills a [`RawSpec`], which then flows through the shared identity/host
//! resolution. A file may hold more than one `agent` node.
//!
//! Only runner-normative fields plus metadata `role` are read; render-only fields (`harness`,
//! `model`, `persona`, `permissions`, `transport`, `strategy`) and the inert `meta{}` block are
//! ignored.
//! Runner-normative fields, managed delivery readiness, and metadata `role` are read. Other
//! render-only fields (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`) and
//! the inert `meta{}` block are ignored.

use crate::declared::{DeclaredDocument, DeclaredNode, DeclaredValue};
use crate::spec::{
ClaudeDriver, CodexDriver, OmpDriver, OpenCodeDriver, PiDriver, RawResource, RawRestart,
RawSpec, RawTask,
ClaudeDriver, CodexDriver, DeliveryReadiness, OmpDriver, OpenCodeDriver, PiDriver, RawResource,
RawRestart, RawSpec, RawTask, SessionDriver,
};

/// Lower an already parsed declaration document into the runner's raw representation.
Expand Down Expand Up @@ -162,6 +162,13 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
anyhow::anyhow!("agent `session-driver` value must be a string")
})?));
}
"delivery-readiness" => {
anyhow::ensure!(
raw.delivery_readiness.is_none(),
"agent declares `delivery-readiness` more than once"
);
raw.delivery_readiness = Some(delivery_readiness_node_to_raw(child)?);
}
"claude" => {
anyhow::ensure!(
raw.driver.claude.is_none(),
Expand Down Expand Up @@ -512,6 +519,68 @@ fn restart_node_to_raw(node: &DeclaredNode) -> RawRestart {
r
}

fn delivery_readiness_node_to_raw(node: &DeclaredNode) -> anyhow::Result<DeliveryReadiness> {
anyhow::ensure!(
node.type_name.is_none() && node.children.is_empty(),
"agent `delivery-readiness` cannot have a type annotation or children"
);
let kind = node
.argument(0)
.and_then(DeclaredValue::as_str)
.ok_or_else(|| anyhow::anyhow!("agent `delivery-readiness` needs a kind string"))?;
match kind {
"credential" => {
anyhow::ensure!(
node.arguments().count() == 1
&& node.properties_named("account-id").count() <= 1
&& node.entries.len() <= 2,
"credential delivery-readiness must be `delivery-readiness \"credential\"` with at most one string `account-id`"
);
let account_id = node
.property("account-id")
.map(|value| {
value.as_str().map(String::from).ok_or_else(|| {
anyhow::anyhow!(
"credential delivery-readiness `account-id` must be a string"
)
})
})
.transpose()?;
Ok(DeliveryReadiness::Credential { account_id })
}
"anonymous" => {
anyhow::ensure!(
node.arguments().count() >= 2
&& node.properties_named("harness").count() == 1
&& node.entries.len() == node.arguments().count() + 1,
"anonymous delivery-readiness must be `delivery-readiness \"anonymous\" \"<model>\"… harness=\"<driver>\"`"
);
let harness = node
.property("harness")
.and_then(DeclaredValue::as_str)
.ok_or_else(|| {
anyhow::anyhow!("anonymous delivery-readiness `harness` must be a string")
})
.and_then(SessionDriver::from_name)?;
let models = node
.arguments()
.skip(1)
.map(|value| {
value.as_str().map(String::from).ok_or_else(|| {
anyhow::anyhow!(
"anonymous delivery-readiness accepts only string model arguments"
)
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(DeliveryReadiness::Anonymous { harness, models })
}
other => anyhow::bail!(
"unsupported delivery-readiness kind '{other}' (expected `credential` or `anonymous`)"
),
}
}

fn task_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawTask> {
let mut t = RawTask::default();
for child in &node.children {
Expand Down
8 changes: 4 additions & 4 deletions crates/agent-spec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ pub use discovery::{
parse_declared, path_defaults,
};
pub use spec::{
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType,
OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, SessionDriver,
Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task,
validate_desired_state_reason,
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryReadiness, DeliveryTransport,
Driver, JobType, OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX,
SessionDriver, Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration,
stream_name_of_task, validate_desired_state_reason,
};
pub use profile::{
DEFAULT_SELECTOR_LIMIT_BYTES, DescriptorValidationError, PROFILE_DESCRIPTOR_ABI_VERSION,
Expand Down
139 changes: 127 additions & 12 deletions crates/agent-spec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,21 @@ impl DeliveryTransport {
),
}
}

pub fn session_driver(self) -> SessionDriver {
match self {
Self::Mcp => SessionDriver::Claude,
Self::AppServer => SessionDriver::Codex,
Self::PiChannel => SessionDriver::Pi,
}
}
}
/// The native session driver entered by an otherwise opaque launch.
///
/// This is an ownership assertion only. It does not render a provider launch or select a message
/// delivery transport.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionDriver {
Claude,
Codex,
Expand Down Expand Up @@ -105,6 +114,71 @@ impl SessionDriver {
),
}
}

/// Parse the canonical driver name carried by `session-driver`.
pub fn from_name(value: &str) -> anyhow::Result<Self> {
Self::parse(value)
}
}

/// Non-secret facts that prove how a managed session can be admitted for delivery.
///
/// This is deliberately separate from activity and runtime health. A credential-backed seat names
/// only its opaque account identifier; an anonymous seat names the exact harness and model allowlist
/// it can launch without credentials.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", deny_unknown_fields)]
pub enum DeliveryReadiness {
Credential {
account_id: Option<String>,
},
Anonymous {
harness: SessionDriver,
models: Vec<String>,
},
}

impl DeliveryReadiness {
pub fn validate(&mut self) -> anyhow::Result<()> {
match self {
Self::Credential { account_id } => {
if let Some(account_id) = account_id {
validate_delivery_readiness_value("account-id", account_id)?;
}
}
Self::Anonymous { harness: _, models } => {
anyhow::ensure!(
!models.is_empty(),
"anonymous delivery-readiness requires at least one model"
);
anyhow::ensure!(
models.len() <= 32,
"anonymous delivery-readiness accepts at most 32 models"
);
for model in models.iter() {
validate_delivery_readiness_value("model", model)?;
}
models.sort();
models.dedup();
}
}
Ok(())
}
}

fn validate_delivery_readiness_value(field: &str, value: &str) -> anyhow::Result<()> {
anyhow::ensure!(
!value.is_empty() && value.len() <= 200,
"delivery-readiness {field} must be 1..=200 UTF-8 bytes"
);
anyhow::ensure!(
value.trim() == value
&& !value
.chars()
.any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')),
"delivery-readiness {field} must have no surrounding whitespace, controls, or line separators"
);
Ok(())
}


Expand All @@ -130,6 +204,16 @@ impl Driver {
Self::Omp(_) => "omp",
}
}

pub fn session_driver(&self) -> SessionDriver {
match self {
Self::Claude(_) => SessionDriver::Claude,
Self::Codex(_) => SessionDriver::Codex,
Self::Pi(_) => SessionDriver::Pi,
Self::OpenCode(_) => SessionDriver::OpenCode,
Self::Omp(_) => SessionDriver::Omp,
}
}
}

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

impl AgentSpec {
/// The explicit native session owner after typed-driver normalization.
pub fn effective_session_driver(&self) -> Option<SessionDriver> {
self.session_driver
.or_else(|| self.driver.as_ref().map(Driver::session_driver))
}
}

fn deserialize_optional_selector<'de, D>(
deserializer: D,
) -> Result<Option<serde_json::Value>, D::Error>
Expand Down Expand Up @@ -603,6 +697,8 @@ pub(crate) struct RawSpec {
/// Native session ownership asserted for an otherwise opaque launch.
#[serde(default, deserialize_with = "deserialize_explicit_optional")]
pub session_driver: Option<Option<String>>,
/// Non-secret facts used to admit the managed delivery path.
pub delivery_readiness: Option<DeliveryReadiness>,
/// Direct typed provider driver block.
#[serde(flatten)]
pub driver: RawDriver,
Expand Down Expand Up @@ -1124,6 +1220,7 @@ impl RawSpec {
|| self.ding
|| self.deliver.is_some()
|| self.session_driver.is_some()
|| self.delivery_readiness.is_some()
|| self.driver.claude.is_some()
|| self.driver.codex.is_some()
// pi predates this predicate gaining driver awareness and was silently skipped too:
Expand Down Expand Up @@ -1171,26 +1268,43 @@ impl RawSpec {
.transpose()?;
let driver = self.driver.lower(&identity)?;
let has_driver = driver.is_some();
let mut delivery_readiness = self.delivery_readiness;
if let Some(readiness) = delivery_readiness.as_mut() {
readiness.validate()?;
}
anyhow::ensure!(
!(self.ding && delivery.is_some()),
"agent '{identity}' declares both `ding` and `deliver`; choose one transport"
);
anyhow::ensure!(
!(self.ding && has_driver),
"agent '{identity}' declares both `ding` and a typed driver; choose one session owner"
!(self.ding && (has_driver || session_driver.is_some() || delivery_readiness.is_some())),
"agent '{identity}' declares managed native delivery together with `ding`; generic Ding is only for opaque non-harness PTYs"
);
if session_driver.is_some() {
anyhow::ensure!(
!self.ding,
"agent '{identity}' declares both `session-driver` and `ding`; choose one session owner"
);
anyhow::ensure!(
!(session_driver.is_some() && has_driver),
"agent '{identity}' declares both `session-driver` and a typed driver; choose one session owner"
);
let effective_session_driver =
session_driver.or_else(|| driver.as_ref().map(Driver::session_driver));
if let (Some(delivery), Some(effective)) = (delivery, effective_session_driver) {
anyhow::ensure!(
delivery.is_none(),
"agent '{identity}' declares both `session-driver` and `deliver`; choose one session owner"
delivery.session_driver() == effective,
"agent '{identity}' delivery transport '{}' requires session-driver '{}', not '{}'",
delivery.as_str(),
delivery.session_driver().as_str(),
effective.as_str()
);
}
if let (
Some(DeliveryReadiness::Anonymous { harness, .. }),
Some(effective),
) = (delivery_readiness.as_ref(), effective_session_driver)
{
anyhow::ensure!(
!has_driver,
"agent '{identity}' declares both `session-driver` and a typed driver; choose one session owner"
*harness == effective,
"agent '{identity}' anonymous delivery-readiness harness '{}' does not match effective session-driver '{}'",
harness.as_str(),
effective.as_str()
);
}
validate_launch(
Expand Down Expand Up @@ -1345,6 +1459,7 @@ impl RawSpec {
delivery,
session_driver,
driver,
delivery_readiness,
resources,
streams,
tasks,
Expand Down
Loading
Loading