Skip to content

Commit 01d0f01

Browse files
feat: expose authoritative agent graph primitives
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 cd45d60 commit 01d0f01

18 files changed

Lines changed: 1142 additions & 55 deletions

crates/agent-spec/src/kdl_format.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,22 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
146146
anyhow::anyhow!("agent `deliver` value must be a string")
147147
})?));
148148
}
149+
"session-driver" => {
150+
anyhow::ensure!(
151+
raw.session_driver.is_none(),
152+
"agent declares `session-driver` more than once"
153+
);
154+
anyhow::ensure!(
155+
child.type_name.is_none()
156+
&& child.children.is_empty()
157+
&& child.entries.len() == 1
158+
&& child.entries[0].name.is_none(),
159+
"agent `session-driver` must contain exactly one positional string"
160+
);
161+
raw.session_driver = Some(Some(arg_string(child).ok_or_else(|| {
162+
anyhow::anyhow!("agent `session-driver` value must be a string")
163+
})?));
164+
}
149165
"claude" => {
150166
anyhow::ensure!(
151167
raw.driver.claude.is_none(),

crates/agent-spec/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ pub use discovery::{
4747
};
4848
pub use spec::{
4949
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType,
50-
OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, Stream,
51-
StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task,
50+
OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, SessionDriver,
51+
Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task,
5252
validate_desired_state_reason,
5353
};
5454
pub use profile::{

crates/agent-spec/src/spec.rs

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
//! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset:
66
//! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only), `type`,
77
//! `workspace`, whole-agent desired state (plus legacy `retired`), `keep`, `supervisor`,
8-
//! `restart{}`, `deliver`, typed harness drivers, task lifecycle, Resource bindings (declaration
9-
//! metadata), and the tasks. Everything else that is render-only (`harness`, `model`, `persona`,
10-
//! `permissions`, legacy `transport` metadata, `strategy`, `meta{}`) is baked into the
11-
//! tasks/commands by the render layer and ignored here.
8+
//! `restart{}`, `deliver`, `session-driver`, typed harness drivers, task lifecycle, Resource
9+
//! bindings (declaration metadata), and the tasks. Everything else that is render-only (`harness`,
10+
//! `model`, `persona`, `permissions`, legacy `transport` metadata, `strategy`, `meta{}`) is baked
11+
//! into the tasks/commands by the render layer and ignored here.
1212
//!
1313
//! Three on-disk formats lower to this model: KDL (canonical, parsed by hand in `kdl_format`), and
1414
//! TOML/JSON (serde). Every spec is a `service` — `type = batch` is retired; evals run through the
@@ -69,6 +69,44 @@ impl DeliveryTransport {
6969
}
7070
}
7171
}
72+
/// The native session driver entered by an otherwise opaque launch.
73+
///
74+
/// This is an ownership assertion only. It does not render a provider launch or select a message
75+
/// delivery transport.
76+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77+
pub enum SessionDriver {
78+
Claude,
79+
Codex,
80+
Pi,
81+
OpenCode,
82+
Omp,
83+
}
84+
85+
impl SessionDriver {
86+
pub fn as_str(self) -> &'static str {
87+
match self {
88+
Self::Claude => "claude",
89+
Self::Codex => "codex",
90+
Self::Pi => "pi",
91+
Self::OpenCode => "opencode",
92+
Self::Omp => "omp",
93+
}
94+
}
95+
96+
fn parse(value: &str) -> anyhow::Result<Self> {
97+
match value {
98+
"claude" => Ok(Self::Claude),
99+
"codex" => Ok(Self::Codex),
100+
"pi" => Ok(Self::Pi),
101+
"opencode" => Ok(Self::OpenCode),
102+
"omp" => Ok(Self::Omp),
103+
_ => anyhow::bail!(
104+
"unsupported `session-driver` value '{value}' (expected `claude`, `codex`, `pi`, `opencode`, or `omp`)"
105+
),
106+
}
107+
}
108+
}
109+
72110

73111
/// One typed harness driver declaration.
74112
///
@@ -216,6 +254,8 @@ pub struct AgentSpec {
216254
pub restart: Option<Restart>,
217255
/// Provider-native delivery selected by `deliver`; `None` means legacy `ding` or no delivery.
218256
pub delivery: Option<DeliveryTransport>,
257+
/// Native session ownership asserted for an otherwise opaque launch.
258+
pub session_driver: Option<SessionDriver>,
219259
/// Typed harness declaration used by task and render compilation.
220260
pub driver: Option<Driver>,
221261
/// Named typed references used by the agent. st2 preserves these for readers but does not
@@ -560,7 +600,10 @@ pub(crate) struct RawSpec {
560600
/// Compact catalog form: select one provider-native delivery transport.
561601
#[serde(default, deserialize_with = "deserialize_explicit_optional")]
562602
pub deliver: Option<Option<String>>,
563-
/// Direct `claude {}` or `codex {}` provider block.
603+
/// Native session ownership asserted for an otherwise opaque launch.
604+
#[serde(default, deserialize_with = "deserialize_explicit_optional")]
605+
pub session_driver: Option<Option<String>>,
606+
/// Direct typed provider driver block.
564607
#[serde(flatten)]
565608
pub driver: RawDriver,
566609
/// Compact catalog form: reconciliation policy for the generated agent PTY.
@@ -1080,6 +1123,7 @@ impl RawSpec {
10801123
|| self.argv.is_some()
10811124
|| self.ding
10821125
|| self.deliver.is_some()
1126+
|| self.session_driver.is_some()
10831127
|| self.driver.claude.is_some()
10841128
|| self.driver.codex.is_some()
10851129
// pi predates this predicate gaining driver awareness and was silently skipped too:
@@ -1121,12 +1165,34 @@ impl RawSpec {
11211165
.as_deref()
11221166
.map(DeliveryTransport::parse)
11231167
.transpose()?;
1168+
let session_driver = reject_explicit_null("session_driver", self.session_driver)?
1169+
.as_deref()
1170+
.map(SessionDriver::parse)
1171+
.transpose()?;
11241172
let driver = self.driver.lower(&identity)?;
11251173
let has_driver = driver.is_some();
11261174
anyhow::ensure!(
11271175
!(self.ding && delivery.is_some()),
11281176
"agent '{identity}' declares both `ding` and `deliver`; choose one transport"
11291177
);
1178+
anyhow::ensure!(
1179+
!(self.ding && has_driver),
1180+
"agent '{identity}' declares both `ding` and a typed driver; choose one session owner"
1181+
);
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+
);
1187+
anyhow::ensure!(
1188+
delivery.is_none(),
1189+
"agent '{identity}' declares both `session-driver` and `deliver`; choose one session owner"
1190+
);
1191+
anyhow::ensure!(
1192+
!has_driver,
1193+
"agent '{identity}' declares both `session-driver` and a typed driver; choose one session owner"
1194+
);
1195+
}
11301196
validate_launch(
11311197
&identity,
11321198
self.command.as_ref(),
@@ -1277,6 +1343,7 @@ impl RawSpec {
12771343
keep: self.keep,
12781344
restart: self.restart.map(RawRestart::lower),
12791345
delivery,
1346+
session_driver,
12801347
driver,
12811348
resources,
12821349
streams,

crates/agent-spec/tests/discovery.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use agent_spec::spec::{
1313
TaskLifecycle,
1414
};
1515
use agent_spec::{
16-
AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_file,
16+
AgentDesiredState, AgentSpec, JobType, Resource, SessionDriver, Task, discover, discover_file,
1717
discover_strict,
1818
};
1919

@@ -390,6 +390,7 @@ agent "cos" {
390390
Some("Silber.cos")
391391
);
392392
assert!(spec.delivery.is_none());
393+
assert!(spec.session_driver.is_none());
393394
assert!(spec.has_delivery_transport());
394395
}
395396

@@ -480,6 +481,165 @@ fn deliver_rejects_unknown_duplicate_mixed_and_malformed_declarations() {
480481
);
481482
}
482483
}
484+
#[test]
485+
fn session_driver_is_closed_ownership_for_an_opaque_launch() {
486+
let tmp = tempfile::tempdir().unwrap();
487+
for driver in ["claude", "codex", "pi", "opencode", "omp"] {
488+
write(
489+
tmp.path(),
490+
&format!("agents/h/{driver}/agent.kdl"),
491+
&format!(
492+
r#"agent "{driver}" {{ host "h"; argv "axe" "agent" "launch"; session-driver "{driver}" }}"#
493+
),
494+
);
495+
}
496+
497+
let found = discover(tmp.path());
498+
assert!(found.errors.is_empty(), "{:?}", found.errors);
499+
for (name, expected) in [
500+
("claude", SessionDriver::Claude),
501+
("codex", SessionDriver::Codex),
502+
("pi", SessionDriver::Pi),
503+
("opencode", SessionDriver::OpenCode),
504+
("omp", SessionDriver::Omp),
505+
] {
506+
let spec = find(&found.specs, name);
507+
assert_eq!(spec.session_driver, Some(expected));
508+
assert_eq!(spec.session_driver.unwrap().as_str(), name);
509+
assert!(spec.driver.is_none());
510+
assert!(spec.delivery.is_none());
511+
assert_eq!(spec.tasks.len(), 1);
512+
assert_eq!(argv(&spec.tasks[0]), ["axe", "agent", "launch"]);
513+
assert!(!spec.tasks[0].derived);
514+
}
515+
}
516+
517+
#[test]
518+
fn session_driver_rejects_unknown_duplicate_malformed_and_conflicting_declarations() {
519+
for (name, declaration, expected) in [
520+
(
521+
"unknown",
522+
r#"agent "worker" { argv "axe"; session-driver "cursor" }"#,
523+
"unsupported `session-driver` value 'cursor'",
524+
),
525+
(
526+
"duplicate",
527+
r#"agent "worker" { argv "axe"; session-driver "claude"; session-driver "codex" }"#,
528+
"declares `session-driver` more than once",
529+
),
530+
(
531+
"missing",
532+
r#"agent "worker" { argv "axe"; session-driver }"#,
533+
"must contain exactly one positional string",
534+
),
535+
(
536+
"non-string",
537+
r#"agent "worker" { argv "axe"; session-driver #true }"#,
538+
"value must be a string",
539+
),
540+
(
541+
"property",
542+
r#"agent "worker" { argv "axe"; session-driver "claude" mode="owner" }"#,
543+
"must contain exactly one positional string",
544+
),
545+
(
546+
"children",
547+
r#"agent "worker" { argv "axe"; session-driver "claude" { prompt "ignored" } }"#,
548+
"must contain exactly one positional string",
549+
),
550+
(
551+
"ding",
552+
r#"agent "worker" { argv "axe"; session-driver "claude"; ding }"#,
553+
"declares both `session-driver` and `ding`",
554+
),
555+
(
556+
"deliver",
557+
r#"agent "worker" { argv "axe"; session-driver "claude"; deliver "mcp" }"#,
558+
"declares both `session-driver` and `deliver`",
559+
),
560+
(
561+
"driver",
562+
r#"agent "worker" { session-driver "claude"; claude { prompt "go" } }"#,
563+
"declares both `session-driver` and a typed driver",
564+
),
565+
] {
566+
let tmp = tempfile::tempdir().unwrap();
567+
write(
568+
tmp.path(),
569+
&format!("agents/h/{name}/agent.kdl"),
570+
declaration,
571+
);
572+
let found = discover(tmp.path());
573+
assert!(found.specs.is_empty(), "{name}: {:?}", found.specs);
574+
assert_eq!(found.errors.len(), 1, "{name}: {:?}", found.errors);
575+
assert!(
576+
found.errors[0].message.contains(expected),
577+
"{name}: expected {expected:?}, got {:?}",
578+
found.errors[0]
579+
);
580+
}
581+
}
582+
583+
#[test]
584+
fn session_driver_lowers_from_toml_and_json_and_rejects_null() {
585+
let tmp = tempfile::tempdir().unwrap();
586+
write(
587+
tmp.path(),
588+
"agents/h/toml/agent.toml",
589+
"identity = \"toml\"\nhost = \"h\"\nargv = [\"axe\"]\nsession_driver = \"codex\"\n",
590+
);
591+
write(
592+
tmp.path(),
593+
"agents/h/json/agent.json",
594+
r#"{"identity":"json","host":"h","argv":["axe"],"session_driver":"pi"}"#,
595+
);
596+
write(
597+
tmp.path(),
598+
"agents/h/null/agent.json",
599+
r#"{"identity":"null","host":"h","argv":["axe"],"session_driver":null}"#,
600+
);
601+
602+
let found = discover(tmp.path());
603+
assert_eq!(find(&found.specs, "toml").session_driver, Some(SessionDriver::Codex));
604+
assert_eq!(find(&found.specs, "json").session_driver, Some(SessionDriver::Pi));
605+
assert_eq!(found.errors.len(), 1, "{:?}", found.errors);
606+
assert!(
607+
found.errors[0]
608+
.message
609+
.contains("field `session_driver` must not be null"),
610+
"{:?}",
611+
found.errors[0]
612+
);
613+
}
614+
615+
#[test]
616+
fn typed_driver_blocks_reject_legacy_ding() {
617+
for (name, driver) in [
618+
("claude", r#"claude { prompt "go" }"#),
619+
("codex", r#"codex { prompt "go" }"#),
620+
("pi", r#"pi { prompt "go" }"#),
621+
("opencode", r#"opencode { prompt "go" }"#),
622+
("omp", r#"omp { prompt "go" }"#),
623+
] {
624+
let tmp = tempfile::tempdir().unwrap();
625+
write(
626+
tmp.path(),
627+
&format!("agents/h/{name}/agent.kdl"),
628+
&format!(r#"agent "{name}" {{ ding; {driver} }}"#),
629+
);
630+
let found = discover(tmp.path());
631+
assert!(found.specs.is_empty(), "{name}: {:?}", found.specs);
632+
assert_eq!(found.errors.len(), 1, "{name}: {:?}", found.errors);
633+
assert!(
634+
found.errors[0]
635+
.message
636+
.contains("declares both `ding` and a typed driver"),
637+
"{name}: {:?}",
638+
found.errors[0]
639+
);
640+
}
641+
}
642+
483643

484644
#[test]
485645
fn compact_adopt_only_lifecycle_lowers_to_the_generated_agent_task() {

0 commit comments

Comments
 (0)