use std::collections::BTreeMap;
use serde::Deserialize;
use crate::{AgentConfig, AgentLimits, LlmProviderRef, MemoryProviderRef, MemorySettings};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct DwApplicationManifest {
pub manifest_id: String,
#[serde(default)]
manifest: DwManifestBody,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
struct DwManifestBody {
#[serde(default)]
capability_plan: DwCapabilityPlan,
#[serde(default)]
defaults: DwDefaults,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
struct DwCapabilityPlan {
#[serde(default)]
default_provider_ids: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
struct DwDefaults {
#[serde(default)]
values: BTreeMap<String, serde_json::Value>,
}
impl DwApplicationManifest {
pub fn llm_provider_id(&self) -> Option<&str> {
self.manifest
.capability_plan
.default_provider_ids
.get("cap://llm/chat")
.map(String::as_str)
}
pub fn memory_provider_id(&self, cap: &str) -> Option<&str> {
self.manifest
.capability_plan
.default_provider_ids
.get(cap)
.map(String::as_str)
}
pub fn system_prompt(&self) -> &str {
self.manifest
.defaults
.values
.get("system_prompt")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
pub fn model_for(&self, provider_id: &str) -> Option<&str> {
let key = format!("{provider_id}::model");
self.manifest
.defaults
.values
.get(&key)
.and_then(serde_json::Value::as_str)
}
}
#[must_use]
pub fn provider_slug(provider_id: &str) -> String {
if let Some(rest) = provider_id.strip_prefix("provider.llm.")
&& let Some((slug, _variant)) = rest.split_once('.')
{
return slug.to_string();
}
provider_id.to_string()
}
#[must_use]
pub fn agent_config_from_dw_manifest(m: &DwApplicationManifest) -> AgentConfig {
if m.llm_provider_id().is_none() {
tracing::warn!(
agent = %m.manifest_id,
"DwApplication manifest declares no LLM provider; leaving llm.provider empty"
);
}
let llm_provider_id = m.llm_provider_id().unwrap_or_default();
let provider = provider_slug(llm_provider_id);
let model = m.model_for(llm_provider_id).unwrap_or_default().to_string();
if model.is_empty() {
tracing::warn!(
agent = %m.manifest_id,
provider_id = %llm_provider_id,
"DwApplication manifest has no model default; leaving llm.model empty"
);
}
AgentConfig {
agent_id: m.manifest_id.clone(),
system_prompt: m.system_prompt().to_string(),
tools: Vec::new(),
guardrails: Vec::new(),
llm: LlmProviderRef {
provider,
model,
credential_ref: None,
},
limits: AgentLimits::default(),
memory: build_memory(m),
knowledge: None,
}
}
fn build_memory(m: &DwApplicationManifest) -> Option<MemorySettings> {
let mem_ref = |cap: &str| {
m.memory_provider_id(cap)
.map(|provider_id| MemoryProviderRef {
provider: provider_id.to_string(),
capability: cap.to_string(),
params: serde_json::Map::new(),
credential_ref: None,
})
};
let short_term = mem_ref("cap://memory/short-term");
let long_term = mem_ref("cap://memory/long-term");
if short_term.is_none() && long_term.is_none() {
None
} else {
Some(MemorySettings {
short_term,
long_term,
})
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
const FIXTURE: &str = r#"{
"manifest_id": "onboarding-companion",
"display_name": "Onboarding Companion",
"manifest": {
"capability_plan": {
"default_provider_ids": {
"cap://llm/chat": "provider.llm.deepseek.chat",
"cap://memory/long-term": "provider.memory.chronicle",
"cap://memory/short-term": "provider.memory.redis"
}
},
"defaults": {
"values": {
"system_prompt": "You are an Onboarding Companion.",
"provider.llm.deepseek.chat::model": "deepseek-chat"
}
}
},
"tenant": "greentic"
}"#;
#[test]
fn parses_manifest_fields() {
let m: DwApplicationManifest = serde_json::from_str(FIXTURE).expect("parse");
assert_eq!(m.manifest_id, "onboarding-companion");
assert_eq!(m.llm_provider_id(), Some("provider.llm.deepseek.chat"));
assert_eq!(
m.memory_provider_id("cap://memory/long-term"),
Some("provider.memory.chronicle")
);
assert_eq!(m.system_prompt(), "You are an Onboarding Companion.");
assert_eq!(
m.model_for("provider.llm.deepseek.chat"),
Some("deepseek-chat")
);
}
#[test]
fn missing_manifest_subtree_defaults() {
let m: DwApplicationManifest =
serde_json::from_str(r#"{"manifest_id":"bare"}"#).expect("parse");
assert_eq!(m.manifest_id, "bare");
assert_eq!(m.llm_provider_id(), None);
assert_eq!(m.memory_provider_id("cap://memory/long-term"), None);
assert_eq!(m.system_prompt(), "");
assert_eq!(m.model_for("x"), None);
}
#[test]
fn absent_system_prompt_returns_empty() {
let m: DwApplicationManifest = serde_json::from_str(
r#"{"manifest_id":"test","manifest":{"defaults":{"values":{"other_key":"value"}}}}"#,
)
.expect("parse");
assert_eq!(m.system_prompt(), "");
}
#[test]
fn non_string_system_prompt_returns_empty() {
let m: DwApplicationManifest = serde_json::from_str(
r#"{"manifest_id":"test","manifest":{"defaults":{"values":{"system_prompt":42}}}}"#,
)
.expect("parse");
assert_eq!(m.system_prompt(), "");
}
#[test]
fn provider_slug_strips_catalog_prefix() {
assert_eq!(provider_slug("provider.llm.deepseek.chat"), "deepseek");
assert_eq!(provider_slug("provider.llm.anthropic.chat"), "anthropic");
assert_eq!(provider_slug("deepseek"), "deepseek");
assert_eq!(provider_slug("provider.llm"), "provider.llm");
}
#[test]
fn converts_manifest_to_agent_config() {
let m: DwApplicationManifest = serde_json::from_str(FIXTURE).expect("parse");
let cfg = agent_config_from_dw_manifest(&m);
assert_eq!(cfg.agent_id, "onboarding-companion");
assert_eq!(cfg.system_prompt, "You are an Onboarding Companion.");
assert_eq!(cfg.llm.provider, "deepseek");
assert_eq!(cfg.llm.model, "deepseek-chat");
assert!(cfg.llm.credential_ref.is_none());
let mem = cfg.memory.expect("memory present");
assert_eq!(
mem.long_term.as_ref().expect("long_term present").provider,
"provider.memory.chronicle"
);
assert_eq!(
mem.long_term
.as_ref()
.expect("long_term present")
.capability,
"cap://memory/long-term"
);
assert_eq!(
mem.short_term
.as_ref()
.expect("short_term present")
.provider,
"provider.memory.redis"
);
assert!(cfg.tools.is_empty());
assert!(cfg.knowledge.is_none());
}
#[test]
fn missing_model_yields_empty_string() {
let json = r#"{"manifest_id":"x","manifest":{"capability_plan":{"default_provider_ids":{"cap://llm/chat":"provider.llm.deepseek.chat"}},"defaults":{"values":{"system_prompt":"hi"}}}}"#;
let m: DwApplicationManifest = serde_json::from_str(json).expect("parse");
let cfg = agent_config_from_dw_manifest(&m);
assert_eq!(cfg.llm.model, "");
assert!(cfg.memory.is_none());
}
#[test]
fn missing_llm_provider_yields_empty_provider() {
let m: DwApplicationManifest =
serde_json::from_str(r#"{"manifest_id":"bare"}"#).expect("parse");
assert_eq!(m.llm_provider_id(), None);
let cfg = agent_config_from_dw_manifest(&m);
assert_eq!(cfg.llm.provider, "");
assert_eq!(cfg.llm.model, "");
}
}