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 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 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
766const CODEX_SUBAGENT_SUPERVISION: &str =
770 include_str!("builtin_prompts/codex_subagent_supervision.md");
771const 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
777pub 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
824pub 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}