Skip to main content

agent_core/
prompt.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use thiserror::Error;
6
7pub const PROMPT_SCHEMA: &str = "synaps-prompt/1";
8pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
9pub const MAX_MODULE_BYTES: usize = 1024 * 1024;
10pub const MAX_COMPOSED_BYTES: usize = 4 * 1024 * 1024;
11
12#[derive(Debug, Error)]
13pub enum PromptError {
14    #[error("invalid prompt data: {0}")]
15    Invalid(String),
16    #[error("unknown prompt module: {0}")]
17    UnknownReference(String),
18    #[error("ambiguous prompt adapters: {0}")]
19    Ambiguous(String),
20}
21
22fn valid_atom(value: &str) -> bool {
23    !value.is_empty()
24        && !value
25            .chars()
26            .any(|c| c.is_whitespace() || c.is_control() || c == '/')
27}
28
29#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct PromptModuleId(String);
32impl PromptModuleId {
33    pub fn parse(value: impl Into<String>) -> Result<Self, PromptError> {
34        let value = value.into();
35        if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
36            return Err(PromptError::Invalid("module id".into()));
37        }
38        Ok(Self(value))
39    }
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43}
44
45#[derive(Clone, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
46#[serde(try_from = "String", into = "String")]
47pub struct QualifiedModelId(String);
48impl TryFrom<String> for QualifiedModelId {
49    type Error = String;
50    fn try_from(value: String) -> Result<Self, Self::Error> {
51        Self::parse(value).map_err(|error| error.to_string())
52    }
53}
54impl From<QualifiedModelId> for String {
55    fn from(value: QualifiedModelId) -> Self {
56        value.0
57    }
58}
59impl QualifiedModelId {
60    pub fn parse(value: impl Into<String>) -> Result<Self, PromptError> {
61        let value = value.into();
62        let mut parts = value.split('/');
63        let provider = parts.next().unwrap_or_default();
64        let rest: Vec<_> = parts.collect();
65        if !valid_atom(provider) || rest.is_empty() || rest.iter().any(|p| !valid_atom(p)) {
66            return Err(PromptError::Invalid(
67                "qualified model must contain non-empty provider/model segments".into(),
68            ));
69        }
70        Ok(Self(value))
71    }
72    pub fn provider(&self) -> &str {
73        self.0.split('/').next().unwrap()
74    }
75    pub fn model(&self) -> &str {
76        &self.0[self.provider().len() + 1..]
77    }
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82impl fmt::Debug for QualifiedModelId {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.debug_tuple("QualifiedModelId").field(&self.0).finish()
85    }
86}
87
88#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
89#[serde(transparent)]
90pub struct ModelFamilyId(String);
91impl ModelFamilyId {
92    pub fn parse(value: impl Into<String>) -> Result<Self, PromptError> {
93        let v = value.into();
94        if !valid_atom(&v) {
95            return Err(PromptError::Invalid("model family".into()));
96        }
97        Ok(Self(v))
98    }
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum WorkflowMode {
107    XHigh,
108    Max,
109    UltraCode,
110}
111
112#[derive(Clone, Debug)]
113pub struct SelectionContext {
114    model: QualifiedModelId,
115    family: Option<ModelFamilyId>,
116    workflow_mode: Option<WorkflowMode>,
117}
118impl SelectionContext {
119    pub fn new(
120        model: QualifiedModelId,
121        family: Option<ModelFamilyId>,
122    ) -> Result<Self, PromptError> {
123        Ok(Self {
124            model,
125            family,
126            workflow_mode: None,
127        })
128    }
129    pub fn with_workflow_mode(mut self, mode: Option<WorkflowMode>) -> Self {
130        self.workflow_mode = mode;
131        self
132    }
133    pub fn workflow_mode(&self) -> Option<WorkflowMode> {
134        self.workflow_mode
135    }
136    pub fn model(&self) -> &QualifiedModelId {
137        &self.model
138    }
139    pub fn family(&self) -> Option<&ModelFamilyId> {
140        self.family.as_ref()
141    }
142}
143
144#[derive(Clone, Debug, Default, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct PromptSelectors {
147    provider: Option<String>,
148    family: Option<ModelFamilyId>,
149    exact: Option<QualifiedModelId>,
150    workflow_mode: Option<WorkflowMode>,
151}
152impl PromptSelectors {
153    pub fn provider(v: impl Into<String>) -> Result<Self, PromptError> {
154        let v = v.into();
155        if !valid_atom(&v) {
156            return Err(PromptError::Invalid("provider selector".into()));
157        }
158        Ok(Self {
159            provider: Some(v),
160            ..Self::default()
161        })
162    }
163    pub fn family(v: ModelFamilyId) -> Self {
164        Self {
165            family: Some(v),
166            ..Self::default()
167        }
168    }
169    pub fn exact(v: QualifiedModelId) -> Self {
170        Self {
171            exact: Some(v),
172            ..Self::default()
173        }
174    }
175    pub fn provider_and_exact(
176        provider: impl Into<String>,
177        exact: QualifiedModelId,
178    ) -> Result<Self, PromptError> {
179        let provider = provider.into();
180        if !valid_atom(&provider) || provider != exact.provider() {
181            return Err(PromptError::Invalid(
182                "provider/exact selector mismatch".into(),
183            ));
184        }
185        Ok(Self {
186            provider: Some(provider),
187            exact: Some(exact),
188            family: None,
189            workflow_mode: None,
190        })
191    }
192    pub fn provider_exact_workflow(
193        provider: impl Into<String>,
194        exact: QualifiedModelId,
195        workflow_mode: WorkflowMode,
196    ) -> Result<Self, PromptError> {
197        let mut selectors = Self::provider_and_exact(provider, exact)?;
198        selectors.workflow_mode = Some(workflow_mode);
199        Ok(selectors)
200    }
201    pub fn workflow_mode(&self) -> Option<WorkflowMode> {
202        self.workflow_mode
203    }
204    fn validate(&self) -> Result<(), PromptError> {
205        if let Some(p) = &self.provider {
206            if !valid_atom(p) {
207                return Err(PromptError::Invalid("provider selector".into()));
208            }
209            if self.exact.as_ref().is_some_and(|e| e.provider() != p) {
210                return Err(PromptError::Invalid(
211                    "provider/exact selector mismatch".into(),
212                ));
213            }
214        }
215        Ok(())
216    }
217    fn layer(&self) -> u8 {
218        if self.exact.is_some() {
219            3
220        } else if self.family.is_some() {
221            2
222        } else if self.provider.is_some() {
223            1
224        } else {
225            0
226        }
227    }
228    fn matches(&self, c: &SelectionContext) -> bool {
229        self.provider
230            .as_deref()
231            .map_or(true, |p| p == c.model.provider())
232            && self
233                .family
234                .as_ref()
235                .map_or(true, |f| c.family.as_ref() == Some(f))
236            && self.exact.as_ref().map_or(true, |e| e == &c.model)
237            && self
238                .workflow_mode
239                .map_or(true, |mode| c.workflow_mode == Some(mode))
240    }
241}
242
243#[derive(Clone, Debug, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum PromptModuleSource {
246    Builtin,
247    User,
248}
249#[derive(Clone, Debug, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum ModuleMutability {
252    ImmutablePolicy,
253    MutableGuidance,
254}
255
256#[derive(Clone)]
257pub struct PromptModule {
258    pub id: PromptModuleId,
259    pub version: String,
260    pub source: PromptModuleSource,
261    pub priority: u16,
262    pub selectors: PromptSelectors,
263    pub mutability: ModuleMutability,
264    content: String,
265    pub sha256: String,
266    safe_path: Option<String>,
267}
268impl fmt::Debug for PromptModule {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.debug_struct("PromptModule")
271            .field("id", &self.id)
272            .field("version", &self.version)
273            .field("sha256", &self.sha256)
274            .field("byte_count", &self.content.len())
275            .finish_non_exhaustive()
276    }
277}
278impl PromptModule {
279    pub fn new(
280        id: PromptModuleId,
281        version: impl Into<String>,
282        source: PromptModuleSource,
283        priority: u16,
284        selectors: PromptSelectors,
285        mutability: ModuleMutability,
286        content: impl Into<String>,
287    ) -> Result<Self, PromptError> {
288        selectors.validate()?;
289        let content = content.into();
290        if content.len() > MAX_MODULE_BYTES {
291            return Err(PromptError::Invalid("module exceeds size limit".into()));
292        }
293        let sha256 = format!("{:x}", Sha256::digest(content.as_bytes()));
294        Ok(Self {
295            id,
296            version: version.into(),
297            source,
298            priority,
299            selectors,
300            mutability,
301            content,
302            sha256,
303            safe_path: None,
304        })
305    }
306    pub fn content(&self) -> &str {
307        &self.content
308    }
309}
310
311pub struct AdapterRegistry {
312    modules: BTreeMap<PromptModuleId, PromptModule>,
313}
314impl AdapterRegistry {
315    pub fn new(modules: Vec<PromptModule>) -> Result<Self, PromptError> {
316        let mut map = BTreeMap::new();
317        for m in modules {
318            if map.insert(m.id.clone(), m).is_some() {
319                return Err(PromptError::Invalid("duplicate module id".into()));
320            }
321        }
322        Ok(Self { modules: map })
323    }
324    pub fn select(&self, c: &SelectionContext) -> Result<Vec<&PromptModule>, PromptError> {
325        let mut v: Vec<_> = self
326            .modules
327            .values()
328            .filter(|m| m.selectors.matches(c) && m.selectors.layer() > 0)
329            .collect();
330        v.sort_by_key(|m| (m.selectors.layer(), m.priority, m.id.as_str()));
331        for w in v.windows(2) {
332            if w[0].selectors.layer() == w[1].selectors.layer() && w[0].priority == w[1].priority {
333                return Err(PromptError::Ambiguous(format!(
334                    "{} and {}",
335                    w[0].id.as_str(),
336                    w[1].id.as_str()
337                )));
338            }
339        }
340        Ok(v)
341    }
342    fn get(&self, id: &str) -> Result<&PromptModule, PromptError> {
343        self.modules
344            .values()
345            .find(|m| m.id.as_str() == id)
346            .ok_or_else(|| PromptError::UnknownReference(id.into()))
347    }
348}
349
350#[derive(Debug, Deserialize)]
351#[serde(deny_unknown_fields)]
352pub struct PromptManifest {
353    schema: String,
354    kernel: String,
355    #[serde(default)]
356    adapters: Vec<String>,
357    #[serde(default)]
358    modules: Vec<ModuleDeclaration>,
359    #[serde(default)]
360    policies: ManifestPolicies,
361}
362#[derive(Debug, Default, Deserialize)]
363#[serde(deny_unknown_fields)]
364struct ManifestPolicies {
365    delegation: Option<ManifestDelegationPolicy>,
366}
367#[derive(Debug, Deserialize)]
368#[serde(deny_unknown_fields)]
369struct ManifestDelegationPolicy {
370    #[serde(alias = "enforcement")]
371    mode: crate::orchestration::EnforcementMode,
372    #[serde(default, alias = "same_provider_models")]
373    allowed_models: Vec<QualifiedModelId>,
374    #[serde(default)]
375    cross_provider_grants: Vec<ManifestCrossProviderGrant>,
376    max_concurrent_workers: usize,
377    max_total_workers: usize,
378}
379#[derive(Debug, Deserialize)]
380#[serde(deny_unknown_fields)]
381struct ManifestCrossProviderGrant {
382    id: String,
383    from_provider: String,
384    to_provider: String,
385    allowed_models: Vec<QualifiedModelId>,
386    #[serde(default)]
387    expires_at: Option<String>,
388}
389#[derive(Debug, Deserialize)]
390#[serde(deny_unknown_fields)]
391struct ModuleDeclaration {
392    id: PromptModuleId,
393    version: String,
394    source: PromptModuleSource,
395    #[serde(default)]
396    path: Option<String>,
397    priority: u16,
398    selectors: PromptSelectors,
399    mutability: ModuleMutability,
400    #[serde(default)]
401    content: Option<String>,
402}
403impl PromptManifest {
404    pub fn parse(input: &str) -> Result<Self, PromptError> {
405        if input.len() > MAX_MANIFEST_BYTES {
406            return Err(PromptError::Invalid("manifest exceeds size limit".into()));
407        }
408        let v: Self =
409            serde_yaml::from_str(input).map_err(|e| PromptError::Invalid(e.to_string()))?;
410        if v.schema != PROMPT_SCHEMA {
411            return Err(PromptError::Invalid("unsupported schema".into()));
412        }
413        if v.kernel.is_empty() {
414            return Err(PromptError::Invalid("kernel is required".into()));
415        }
416        let mut ids = BTreeSet::new();
417        for m in &v.modules {
418            m.selectors.validate()?;
419            if !ids.insert(&m.id) {
420                return Err(PromptError::Invalid("duplicate module id".into()));
421            }
422            if m.content
423                .as_ref()
424                .is_some_and(|content| content.len() > MAX_MODULE_BYTES)
425            {
426                return Err(PromptError::Invalid("module exceeds size limit".into()));
427            }
428            if m.path.is_some() == m.content.is_some() {
429                return Err(PromptError::Invalid(format!(
430                    "module {} must declare exactly one of path or content",
431                    m.id.as_str()
432                )));
433            }
434        }
435        if let Some(policy) = &v.policies.delegation {
436            if policy.max_concurrent_workers == 0
437                || policy.max_total_workers == 0
438                || policy.max_concurrent_workers > policy.max_total_workers
439                || (policy.mode != crate::orchestration::EnforcementMode::Off
440                    && policy.allowed_models.is_empty())
441            {
442                return Err(PromptError::Invalid(
443                    "invalid delegation policy limits".into(),
444                ));
445            }
446        }
447        Ok(v)
448    }
449    pub fn delegation_catalog_candidates(&self) -> Vec<QualifiedModelId> {
450        let mut models = Vec::new();
451        if let Some(policy) = &self.policies.delegation {
452            models.extend(policy.allowed_models.iter().cloned());
453            for grant in &policy.cross_provider_grants {
454                models.extend(grant.allowed_models.iter().cloned());
455            }
456        }
457        models
458    }
459
460    pub fn delegation_policy(
461        &self,
462        foreground: QualifiedModelId,
463        catalog: &crate::orchestration::CatalogSnapshot,
464    ) -> Result<Option<crate::orchestration::DelegationPolicy>, PromptError> {
465        let Some(policy) = self.policies.delegation.as_ref() else {
466            return Ok(None);
467        };
468        if policy.mode != crate::orchestration::EnforcementMode::Enforced {
469            return Err(PromptError::Invalid(
470                "delegation policy must use enforced mode".into(),
471            ));
472        }
473        let mut grants = Vec::with_capacity(policy.cross_provider_grants.len());
474        for grant in &policy.cross_provider_grants {
475            // Expiring grants require a trusted clock and atomic refresh support. Until
476            // that exists, accepting them would risk use after expiry, so fail closed.
477            if grant.expires_at.is_some() {
478                return Err(PromptError::Invalid(
479                    "expiring cross-provider grants are not supported".into(),
480                ));
481            }
482            grants.push(
483                crate::orchestration::CrossProviderGrant::new(
484                    grant.id.clone(),
485                    grant.from_provider.clone(),
486                    grant.to_provider.clone(),
487                    grant.allowed_models.clone(),
488                )
489                .map_err(|error| PromptError::Invalid(error.into()))?,
490            );
491        }
492        crate::orchestration::DelegationPolicy::with_grants(
493            foreground,
494            catalog.clone(),
495            policy.allowed_models.clone(),
496            grants,
497            policy.max_concurrent_workers,
498            policy.max_total_workers,
499        )
500        .map(Some)
501        .map_err(|error| PromptError::Invalid(error.into()))
502    }
503    pub fn registry(
504        &self,
505        manifest_dir: Option<&std::path::Path>,
506    ) -> Result<AdapterRegistry, PromptError> {
507        let mut modules = Vec::with_capacity(self.modules.len());
508        for declaration in &self.modules {
509            let (content, safe_path) = if let Some(content) = &declaration.content {
510                (content.clone(), None)
511            } else {
512                use std::path::Component;
513                let relative = std::path::Path::new(declaration.path.as_deref().unwrap());
514                if relative.is_absolute()
515                    || relative.components().any(|component| {
516                        !matches!(component, Component::Normal(_) | Component::CurDir)
517                    })
518                {
519                    return Err(PromptError::Invalid(
520                        "module path must be confined to manifest directory".into(),
521                    ));
522                }
523                let base = manifest_dir.ok_or_else(|| {
524                    PromptError::Invalid("manifest directory is required for module paths".into())
525                })?;
526                let canonical_base = base.canonicalize().map_err(|_| {
527                    PromptError::Invalid("manifest directory is unavailable".into())
528                })?;
529                let candidate = base.join(relative);
530                let canonical_candidate = candidate
531                    .canonicalize()
532                    .map_err(|_| PromptError::Invalid("module path is unavailable".into()))?;
533                if !canonical_candidate.starts_with(&canonical_base) {
534                    return Err(PromptError::Invalid(
535                        "module path must be confined to manifest directory".into(),
536                    ));
537                }
538                let content = std::fs::read_to_string(&canonical_candidate).map_err(|_| {
539                    PromptError::Invalid(format!(
540                        "module {} could not be read",
541                        declaration.id.as_str()
542                    ))
543                })?;
544                (content, Some(relative.to_string_lossy().into_owned()))
545            };
546            let mut module = PromptModule::new(
547                declaration.id.clone(),
548                declaration.version.clone(),
549                declaration.source.clone(),
550                declaration.priority,
551                declaration.selectors.clone(),
552                declaration.mutability.clone(),
553                content,
554            )?;
555            module.safe_path = safe_path;
556            modules.push(module);
557        }
558        AdapterRegistry::new(modules)
559    }
560    pub fn schema(&self) -> &str {
561        &self.schema
562    }
563    pub fn validate_references<'a>(
564        &self,
565        known: impl IntoIterator<Item = &'a str>,
566    ) -> Result<(), PromptError> {
567        let k: BTreeSet<_> = known.into_iter().collect();
568        for id in std::iter::once(&self.kernel).chain(&self.adapters) {
569            if !k.contains(id.as_str()) {
570                return Err(PromptError::UnknownReference(id.clone()));
571            }
572        }
573        Ok(())
574    }
575}
576
577#[derive(Clone, Debug, Serialize, Deserialize)]
578pub struct PromptProvenance {
579    pub prompt_schema: String,
580    pub prompt_stack: Vec<PromptProvenanceModule>,
581    pub delegation_policy_digest: String,
582    pub foreground_model: String,
583}
584#[derive(Clone, Debug, Serialize, Deserialize)]
585pub struct PromptProvenanceModule {
586    pub id: String,
587    pub version: String,
588    pub sha256: String,
589}
590
591#[derive(Clone)]
592pub struct PromptStack {
593    modules: Vec<PromptModule>,
594    context: SelectionContext,
595    composed: String,
596}
597impl fmt::Debug for PromptStack {
598    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599        f.debug_struct("PromptStack")
600            .field("modules", &self.modules)
601            .field("context", &self.context)
602            .field("byte_count", &self.composed.len())
603            .finish()
604    }
605}
606#[derive(Serialize)]
607pub struct PromptInspection<'a> {
608    schema: &'static str,
609    foreground_model: &'a str,
610    enforcement_state: &'static str,
611    byte_count: usize,
612    modules: Vec<PromptModuleMetadata<'a>>,
613}
614#[derive(Serialize)]
615pub struct PromptModuleMetadata<'a> {
616    id: &'a PromptModuleId,
617    version: &'a str,
618    sha256: &'a str,
619    selectors: &'a PromptSelectors,
620    mutability: &'a ModuleMutability,
621    source: &'a PromptModuleSource,
622    safe_source: Option<&'a str>,
623    byte_count: usize,
624}
625impl PromptStack {
626    pub fn new(modules: Vec<PromptModule>, context: SelectionContext) -> Result<Self, PromptError> {
627        let composed = modules
628            .iter()
629            .map(|m| m.content())
630            .collect::<Vec<_>>()
631            .join("\n");
632        if composed.len() > MAX_COMPOSED_BYTES {
633            return Err(PromptError::Invalid(
634                "composed prompt exceeds size limit".into(),
635            ));
636        }
637        Ok(Self {
638            modules,
639            context,
640            composed,
641        })
642    }
643    pub fn modules(&self) -> &[PromptModule] {
644        &self.modules
645    }
646    pub fn composed(&self) -> &str {
647        &self.composed
648    }
649    /// Validate a candidate stack completely before the caller atomically swaps it.
650    pub fn validate_hot_reload(&self, candidate: &PromptStack) -> Result<(), PromptError> {
651        if self.context.model != candidate.context.model {
652            return Err(PromptError::Invalid(
653                "hot reload cannot change foreground model".into(),
654            ));
655        }
656        let immutable = |stack: &PromptStack| {
657            stack
658                .modules
659                .iter()
660                .filter(|module| matches!(module.mutability, ModuleMutability::ImmutablePolicy))
661                .map(|module| (module.id.as_str().to_owned(), module.sha256.clone()))
662                .collect::<Vec<_>>()
663        };
664        if immutable(self) != immutable(candidate) {
665            return Err(PromptError::Invalid(
666                "hot reload cannot change immutable policy".into(),
667            ));
668        }
669        Ok(())
670    }
671    pub fn provenance(&self, delegation_policy_digest: impl Into<String>) -> PromptProvenance {
672        PromptProvenance {
673            prompt_schema: PROMPT_SCHEMA.into(),
674            prompt_stack: self
675                .modules
676                .iter()
677                .map(|module| PromptProvenanceModule {
678                    id: module.id.as_str().into(),
679                    version: module.version.clone(),
680                    sha256: module.sha256.clone(),
681                })
682                .collect(),
683            delegation_policy_digest: delegation_policy_digest.into(),
684            foreground_model: self.context.model.as_str().into(),
685        }
686    }
687    pub fn inspect(
688        &self,
689        enforcement_mode: crate::orchestration::EnforcementMode,
690    ) -> PromptInspection<'_> {
691        let enforcement_state = match enforcement_mode {
692            crate::orchestration::EnforcementMode::Off => "off",
693            crate::orchestration::EnforcementMode::Advisory => "advisory",
694            crate::orchestration::EnforcementMode::Enforced => "enforced",
695        };
696        PromptInspection {
697            schema: PROMPT_SCHEMA,
698            foreground_model: self.context.model.as_str(),
699            enforcement_state,
700            byte_count: self.composed.len(),
701            modules: self
702                .modules
703                .iter()
704                .map(|m| PromptModuleMetadata {
705                    id: &m.id,
706                    version: &m.version,
707                    sha256: &m.sha256,
708                    selectors: &m.selectors,
709                    mutability: &m.mutability,
710                    source: &m.source,
711                    safe_source: m.safe_path.as_deref(),
712                    byte_count: m.content.len(),
713                })
714                .collect(),
715        }
716    }
717}
718
719pub fn compile_prompt_stack(
720    manifest: &PromptManifest,
721    registry: &AdapterRegistry,
722    context: &SelectionContext,
723    user: Option<PromptModule>,
724) -> Result<PromptStack, PromptError> {
725    let mut out = vec![registry.get(&manifest.kernel)?.clone()];
726    let requested: BTreeSet<_> = manifest.adapters.iter().map(String::as_str).collect();
727    let selected = registry.select(context)?;
728    for id in &requested {
729        let module = registry.get(id)?;
730        if !selected.iter().any(|candidate| candidate.id == module.id) {
731            return Err(PromptError::Invalid(format!(
732                "requested adapter {id} does not match selection context"
733            )));
734        }
735    }
736    for m in selected {
737        if requested.contains(m.id.as_str()) {
738            out.push(m.clone())
739        }
740    }
741    if let Some(u) = user {
742        if out.iter().any(|m| m.id == u.id) {
743            return Err(PromptError::Invalid("duplicate module id".into()));
744        }
745        out.push(u)
746    }
747    PromptStack::new(out, context.clone())
748}
749pub fn resolved_system_prompt_as_user_module(
750    content: impl Into<String>,
751) -> Result<PromptModule, PromptError> {
752    PromptModule::new(
753        PromptModuleId::parse("user.system")?,
754        "legacy",
755        PromptModuleSource::User,
756        u16::MAX,
757        PromptSelectors::default(),
758        ModuleMutability::MutableGuidance,
759        content,
760    )
761}
762pub fn resolve_system_prompt_module(content: impl Into<String>) -> PromptModule {
763    resolved_system_prompt_as_user_module(content).expect("legacy prompt is within size limit")
764}
765
766/// Subagent supervision doctrine for OpenAI Codex models: keep a polling
767/// loop open until every dispatched worker reaches terminal status, then
768/// collect each terminal handle with reconciliation before ending the turn.
769const CODEX_SUBAGENT_SUPERVISION: &str =
770    include_str!("builtin_prompts/codex_subagent_supervision.md");
771/// Project-memory lookup doctrine for OpenAI Codex models: literal bounded
772/// searches must complete before exact returned record IDs can be fetched.
773const CODEX_PROJECT_MEMORY: &str = include_str!("builtin_prompts/codex_project_memory.md");
774const ANTHROPIC_ULTRACODE_WORKFLOW: &str =
775    include_str!("builtin_prompts/anthropic_ultracode_workflow.md");
776
777/// Builtin orchestration prompt adapters, shipped in source. Selection is
778/// typed: adapters carry exact [`PromptSelectors`] (including provider-level
779/// `openai-codex` modules), so matching is provider-atom equality — never
780/// substring or inferred-family matching.
781pub fn builtin_orchestration_adapters() -> Vec<PromptModule> {
782    vec![
783        PromptModule::new(
784            PromptModuleId::parse("builtin.codex.subagent-supervision")
785                .expect("builtin module id is valid"),
786            "1.0.1",
787            PromptModuleSource::Builtin,
788            10,
789            PromptSelectors::provider("openai-codex").expect("builtin provider selector is valid"),
790            ModuleMutability::MutableGuidance,
791            CODEX_SUBAGENT_SUPERVISION.trim_end(),
792        )
793        .expect("builtin doctrine is within size limit"),
794        PromptModule::new(
795            PromptModuleId::parse("builtin.codex.project-memory")
796                .expect("builtin module id is valid"),
797            "1.0.0",
798            PromptModuleSource::Builtin,
799            11,
800            PromptSelectors::provider("openai-codex").expect("builtin provider selector is valid"),
801            ModuleMutability::MutableGuidance,
802            CODEX_PROJECT_MEMORY.trim_end(),
803        )
804        .expect("builtin doctrine is within size limit"),
805        PromptModule::new(
806            PromptModuleId::parse("builtin.anthropic.ultracode-workflow")
807                .expect("builtin module id is valid"),
808            "1.0.0",
809            PromptModuleSource::Builtin,
810            20,
811            PromptSelectors::provider_exact_workflow(
812                "anthropic",
813                QualifiedModelId::parse("anthropic/claude-fable-5").expect("valid exact model"),
814                WorkflowMode::UltraCode,
815            )
816            .expect("builtin selector is valid"),
817            ModuleMutability::MutableGuidance,
818            ANTHROPIC_ULTRACODE_WORKFLOW.trim_end(),
819        )
820        .expect("builtin doctrine is within size limit"),
821    ]
822}
823
824/// Compose `base` with every builtin orchestration adapter matching
825/// `context`, in registry selection order. When nothing matches, `base`
826/// passes through byte-identical (`None` stays `None`); internal registry
827/// errors also fall back to `base` — composition must never take a session
828/// down.
829pub fn compose_orchestration_prompt(
830    base: Option<&str>,
831    context: &SelectionContext,
832) -> Option<String> {
833    let fallback = || base.map(str::to_owned);
834    let Ok(registry) = AdapterRegistry::new(builtin_orchestration_adapters()) else {
835        return fallback();
836    };
837    let Ok(selected) = registry.select(context) else {
838        return fallback();
839    };
840    if selected.is_empty() {
841        return fallback();
842    }
843    let mut parts: Vec<&str> = Vec::with_capacity(1 + selected.len());
844    if let Some(base) = base {
845        if !base.is_empty() {
846            parts.push(base);
847        }
848    }
849    parts.extend(selected.iter().map(|module| module.content()));
850    Some(parts.join("\n\n"))
851}