Skip to content

Commit ec3a030

Browse files
committed
feat(config): resolve shared and inline agent integrations
Shared definition and agent-inline tests initially failed because those input locations were unsupported. Resolve named deployment/sandbox maps through explicit agent integrationRefs, and attach agent-inline maps directly. Reject missing or duplicate references and name shadowing. Derive Brave resources and native grants from attached definitions while preserving authored intent and the adapter wire contract. Leave unused definitions inactive and retain the one-search-definition runtime limit. Keep anchors, aliases, and YAML merges disabled. Regenerate the public schema and field reference alongside the typed input changes. Validation: cargo fmt --check, workspace Clippy with warnings denied, cargo test --workspace, and the verified-bundle web-search lifecycle fixture for deployment, sandbox, and agent-inline declarations. The fixture covers export/reapply, drift rejection, and credential-free teardown against local OpenShell responses, not live Brave service.
1 parent f4ec449 commit ec3a030

17 files changed

Lines changed: 588 additions & 147 deletions

File tree

‎crates/nemoclaw-build/src/schema/reference.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ fn guide(name: &str) -> &'static str {
178178
| "HermesInterfaces" | "HermesDashboard" | "HermesApi" | "HermesTui" => {
179179
"[Agent interfaces](../interfaces.md)"
180180
}
181-
"Integrations" | "WebSearch" | "SearchProvider" | "Agent" | "AgentExecution"
181+
"Integration" | "WebSearch" | "SearchProvider" | "Agent" | "AgentExecution"
182182
| "AgentObservability" | "OtlpTracing" | "RelayTracing" | "AgentTools" | "AllowedTool"
183183
| "ToolDisclosure" => "[Agent runtimes](../agents.md)",
184184
_ => "[Configuration and credentials](../usage.md#configuration-and-credentials)",

‎crates/nemoclaw-e2e/tests/deployment.rs‎

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,17 @@ async fn hermes_interfaces_sdk_export_reapply_and_drift() {
110110
async fn web_search_cli_export_reapply_and_destroy() {
111111
let mut document =
112112
Document::parse(include_str!("../../../examples/fabric-openclaw.yaml").as_bytes()).unwrap();
113-
document.spec.sandboxes[0].integrations = Some(
114-
serde_json::from_value(serde_json::json!({
115-
"webSearch":{"provider":"brave","agentRefs":["main"],"credential":{"env":"SEARCH_KEY"}}
116-
}))
117-
.unwrap(),
118-
);
113+
document.spec.integrations = serde_json::from_value(serde_json::json!({
114+
"search":{"kind":"webSearch","provider":"brave","credential":{"env":"SEARCH_KEY"}}
115+
}))
116+
.unwrap();
117+
document.spec.sandboxes[0].agents[0].integration_refs = vec!["search".into()];
118+
lifecycle(&document.yaml().unwrap()).await;
119+
document.spec.sandboxes[0].integrations = std::mem::take(&mut document.spec.integrations);
120+
lifecycle(&document.yaml().unwrap()).await;
121+
let sandbox = &mut document.spec.sandboxes[0];
122+
sandbox.agents[0].integration_refs.clear();
123+
sandbox.agents[0].integrations = std::mem::take(&mut sandbox.integrations);
119124
lifecycle(&document.yaml().unwrap()).await;
120125
}
121126

@@ -150,6 +155,10 @@ async fn lifecycle_with_ownership(input: &str, declare_ownership: bool) {
150155
.path = Some("/docs/${file}/%{literal}".into());
151156
}
152157
document.spec.gateway.endpoint = fixture.endpoint.clone();
158+
let has_search = !document.spec.sandboxes[0]
159+
.integration_bindings(&document.spec.integrations)
160+
.unwrap()
161+
.is_empty();
153162
struct FixtureSecrets;
154163
impl nemoclaw_sdk::openshell::Secrets for FixtureSecrets {
155164
fn resolve(&self, name: &str) -> Result<String, nemoclaw_sdk::ObservationError> {
@@ -176,14 +185,7 @@ async fn lifecycle_with_ownership(input: &str, declare_ownership: bool) {
176185
let applied = deployment.apply(&document, &cancel).await;
177186
assert!(applied.is_ok(), "{applied:?}");
178187
let effects = fixture.state.lock().unwrap().effects;
179-
assert_eq!(
180-
effects,
181-
if document.spec.sandboxes[0].integrations.is_some() {
182-
6
183-
} else {
184-
4
185-
}
186-
);
188+
assert_eq!(effects, if has_search { 6 } else { 4 });
187189
if declare_ownership {
188190
document.spec.inference_providers[0].management =
189191
Some(nemoclaw_sdk::config::Management::External);
@@ -236,7 +238,7 @@ async fn lifecycle_with_ownership(input: &str, declare_ownership: bool) {
236238
.is_empty()
237239
);
238240
assert_eq!(fixture.state.lock().unwrap().effects, effects);
239-
if document.spec.sandboxes[0].integrations.is_some() {
241+
if has_search {
240242
let state_bytes = fs::read(directory.path().join("terraform.tfstate")).unwrap();
241243
let key = format!("{}/nemoclaw-brave", document.workspace());
242244
let profile = fixture.state.lock().unwrap().profiles[&key].clone();
@@ -427,14 +429,7 @@ async fn lifecycle_with_ownership(input: &str, declare_ownership: bool) {
427429
.policy = original;
428430
}
429431
let preview = deployment.plan_destroy(&cancel).await.unwrap();
430-
assert_eq!(
431-
preview.changes.len(),
432-
if document.spec.sandboxes[0].integrations.is_some() {
433-
5
434-
} else {
435-
3
436-
}
437-
);
432+
assert_eq!(preview.changes.len(), if has_search { 5 } else { 3 });
438433
assert_eq!(fixture.state.lock().unwrap().effects, effects);
439434
let destroyed = Command::new(
440435
bundle
@@ -466,11 +461,7 @@ async fn lifecycle_with_ownership(input: &str, declare_ownership: bool) {
466461
.unwrap()
467462
.changes
468463
.len(),
469-
if document.spec.sandboxes[0].integrations.is_some() {
470-
5
471-
} else {
472-
3
473-
}
464+
if has_search { 5 } else { 3 }
474465
);
475466
}
476467

‎crates/nemoclaw-e2e/tests/web_search.rs‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,11 @@ async fn search_owns_profile_and_provider_preserves_secret_custody_and_rejects_p
2222
let mut doc =
2323
Document::parse(include_str!("../../../examples/fabric-openclaw.yaml").as_bytes()).unwrap();
2424
doc.spec.gateway.endpoint = fixture.endpoint.clone();
25-
doc.spec.sandboxes[0].integrations = Some(
26-
serde_json::from_value(serde_json::json!({
27-
"webSearch":{"provider":"brave","agentRefs":["main"],"credential":{"env":"SEARCH_KEY"}}
28-
}))
29-
.unwrap(),
30-
);
25+
doc.spec.integrations = serde_json::from_value(serde_json::json!({
26+
"search":{"kind":"webSearch","provider":"brave","credential":{"env":"SEARCH_KEY"}}
27+
}))
28+
.unwrap();
29+
doc.spec.sandboxes[0].agents[0].integration_refs = vec!["search".into()];
3130
let client = OpenShell::connect(&doc.spec.gateway, Arc::new(Key)).unwrap();
3231
let generations: Generations = ["workspace", "provider", "sandbox"]
3332
.map(|k| (k.into(), "a".repeat(32)))

‎crates/nemoclaw-sdk/src/compile.rs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,16 @@ pub fn targets(document: &Document, generations: &Generations) -> Result<Vec<Tar
103103
}
104104
values.extend(extra.into_iter().map(|(k, v)| (k.into(), v)));
105105
if kind == "sandbox" {
106-
if let Some(settings) = document.runtime_inference() {
106+
if let Some(settings) = document.runtime_inference()? {
107107
values.insert(
108108
"inference_json".into(),
109109
serde_json::to_string(&settings).expect("typed inference settings"),
110110
);
111111
}
112-
let policy = crate::openshell::policy_json(&sandbox.policy_proto()?)
113-
.map_err(|_| ConfigError("cannot encode sandbox policy"))?;
112+
let policy = crate::openshell::policy_json(
113+
&sandbox.policy_proto(document.web_search()?.is_some())?,
114+
)
115+
.map_err(|_| ConfigError("cannot encode sandbox policy"))?;
114116
if !policy.is_empty() {
115117
values.insert("policy_json".into(), policy);
116118
}
@@ -125,7 +127,7 @@ pub fn targets(document: &Document, generations: &Generations) -> Result<Vec<Tar
125127
values,
126128
});
127129
}
128-
if let Some(search) = sandbox.web_search() {
130+
if let Some(search) = document.web_search()? {
129131
for (kind, name) in [
130132
("provider_profile", "nemoclaw-brave"),
131133
("provider", "brave-search"),
@@ -267,7 +269,7 @@ pub fn compile(
267269
attributes[field] = json!(value.replace("${", "$${").replace("%{", "%%{"));
268270
}
269271
}
270-
attributes["depends_on"] = if document.spec.sandboxes[0].web_search().is_some() {
272+
attributes["depends_on"] = if document.web_search()?.is_some() {
271273
json!(["nemoclaw_route.primary", "nemoclaw_provider.web_search"])
272274
} else {
273275
json!(["nemoclaw_route.primary"])

‎crates/nemoclaw-sdk/src/config/agent_inference.rs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ pub(crate) struct RuntimeAgent {
148148
#[serde(deny_unknown_fields)]
149149
pub(crate) struct RuntimeInference {
150150
#[serde(rename = "webSearch", default, skip_serializing_if = "Option::is_none")]
151-
pub web_search: Option<WebSearch>,
151+
pub web_search: Option<RuntimeWebSearch>,
152152
#[serde(default, skip_serializing_if = "Option::is_none")]
153153
pub observability: Option<AgentObservability>,
154154
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -210,23 +210,23 @@ impl RuntimeInference {
210210
}
211211
}
212212
impl Document {
213-
pub(crate) fn runtime_inference(&self) -> Option<RuntimeInference> {
213+
pub(crate) fn runtime_inference(&self) -> Result<Option<RuntimeInference>, ConfigError> {
214214
let agent = &self.spec.sandboxes[0].agents[0];
215215
let provider = &self.spec.inference_providers[0];
216216
let tuning = &agent.inference.routes[0].overrides.tuning;
217217
let agents = &self.spec.sandboxes[0].agents;
218-
let roster = self.spec.sandboxes[0].web_search().is_some()
219-
|| agents.len() > 1
220-
|| agents.iter().any(|a| a.tools.is_some());
221-
(provider.api.is_some()
218+
let web_search = self.web_search()?;
219+
let roster =
220+
web_search.is_some() || agents.len() > 1 || agents.iter().any(|a| a.tools.is_some());
221+
Ok((provider.api.is_some()
222222
|| tuning != &RouteTuning::default()
223223
|| agent.auth.is_some()
224224
|| roster
225225
|| agent.execution.is_some()
226226
|| agent.observability.is_some()
227227
|| agent.interfaces.is_some())
228228
.then(|| RuntimeInference {
229-
web_search: self.spec.sandboxes[0].web_search().cloned(),
229+
web_search,
230230
observability: agent.observability.clone(),
231231
execution: agent.execution.clone(),
232232
interfaces: agent.interfaces.clone(),
@@ -246,6 +246,6 @@ impl Document {
246246
.unwrap_or(InferenceApi::for_harness(&agent.harness)),
247247
tuning: tuning.clone(),
248248
auth: agent.auth.clone(),
249-
})
249+
}))
250250
}
251251
}

‎crates/nemoclaw-sdk/src/config/integration_policy.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ use openshell_core::proto;
88
use serde_json::json;
99

1010
impl Sandbox {
11-
pub(crate) fn policy_proto(&self) -> Result<proto::SandboxPolicy, ConfigError> {
11+
pub(crate) fn policy_proto(
12+
&self,
13+
web_search: bool,
14+
) -> Result<proto::SandboxPolicy, ConfigError> {
1215
let base = self.network.policy_proto()?;
1316
if self
1417
.agents
1518
.first()
1619
.is_none_or(|a| a.observability.is_none())
17-
&& self.web_search().is_none()
20+
&& !web_search
1821
{
1922
return Ok(base);
2023
}
@@ -52,7 +55,7 @@ impl Sandbox {
5255
.expect("typed OTLP policy"),
5356
);
5457
}
55-
if self.web_search().is_some() {
58+
if web_search {
5659
if policy.network_policies.contains_key("nemoclaw-brave") {
5760
return Err(ConfigError("nemoclaw-brave is reserved for web search"));
5861
}

0 commit comments

Comments
 (0)