Skip to main content

perspt_core/
prompts.rs

1//! The platform prompt section library (PSP-10 systems 23 and 25).
2//!
3//! The five formerly inline actor prompts live here as base sections,
4//! compiled at build time from `prompts/` by `perspt-prompt-macros`. There
5//! is exactly one composer: the SDK's `StageComposition` compiler, which
6//! fits the dialect budget deterministically and digests the program.
7//! Under a single-slot dialect the compiled system text of each migrated
8//! stage is byte-identical to the pre-move literal
9//! (`tests/prompt_matrix.rs` proves it). Construction is lazy: nothing
10//! composes until an actor asks, so `simple-chat` links this crate without
11//! ever building a program.
12
13use perspt_sdk::error::Result;
14use perspt_sdk::prompt::{
15    CompiledPromptProgram, ManifestEntry, ModelDialect, PromptManifest, PromptMessageRole,
16    PromptRoute, PromptSectionId, PromptSectionVersion, PromptStage, RenderedSection,
17    SectionTemplate, StageComposition,
18};
19
20mod generated {
21    include!(concat!(env!("OUT_DIR"), "/prompt_sections.rs"));
22}
23
24pub use generated::{
25    adjudicate, evidence_summarize, graph_plan, repository_explore, session_bootstrap,
26};
27
28/// Experimental live section overrides loaded from validated
29/// `[prompts].bundles` (system 25). A replacement body substitutes only
30/// for a known compiled **platform** section, only under exactly that
31/// section's schema, and only when the operator opted in with
32/// `--allow-experimental-prompts`; the composition root ledgers every
33/// active override at session start. Gate AE keeps divergent templates
34/// experimental until a `PromptChangeRecord` passes the paired evaluation.
35#[derive(Debug, Clone, Default)]
36pub struct SectionOverrides {
37    map: std::collections::BTreeMap<String, perspt_sdk::prompt::SectionTemplate>,
38}
39
40impl SectionOverrides {
41    /// Register one replacement body for a known platform section. The
42    /// compiled schema is authoritative — only the body and its content
43    /// hash change. Unknown or non-platform ids refuse loudly.
44    pub fn insert_replacement(&mut self, id: &str, body: &str) -> Result<()> {
45        let base = compiled_platform_templates()
46            .into_iter()
47            .map(|(_, template)| template)
48            .find(|template| template.schema.id.0 == id)
49            .ok_or_else(|| {
50                perspt_sdk::error::SdkError::Domain(format!(
51                    "section {id:?} is not a compiled platform section; live \
52                     substitution covers the five platform stages only"
53                ))
54            })?;
55        let template = perspt_sdk::prompt::SectionTemplate {
56            schema: base.schema,
57            content_hash: perspt_sdk::prompt::SectionTemplate::hash_body(body),
58            body: body.to_string(),
59        };
60        self.map.insert(id.to_string(), template);
61        Ok(())
62    }
63
64    pub fn is_empty(&self) -> bool {
65        self.map.is_empty()
66    }
67
68    /// `(section id, replacement content hash)` pairs for the ledger.
69    pub fn provenance(&self) -> Vec<(String, String)> {
70        self.map
71            .iter()
72            .map(|(id, template)| (id.clone(), template.content_hash.clone()))
73            .collect()
74    }
75
76    /// Render one section: the override body under the compiled schema when
77    /// one is loaded, the base template otherwise. An override body renders
78    /// with exactly the base section's typed values and fails closed on any
79    /// undeclared placeholder.
80    fn rendered(
81        &self,
82        base: perspt_sdk::prompt::SectionTemplate,
83        values: &std::collections::BTreeMap<String, perspt_sdk::prompt::VarValue>,
84    ) -> Result<RenderedSection> {
85        match self.map.get(base.schema.id.0.as_str()) {
86            Some(replacement) => replacement.render(values),
87            None => base.render(values),
88        }
89    }
90}
91
92/// Every compiled platform base template with its stage, in manifest order.
93pub fn compiled_platform_templates() -> Vec<(&'static str, SectionTemplate)> {
94    let stages: [(&'static str, Vec<SectionTemplate>); 5] = [
95        ("adjudicate", adjudicate::templates()),
96        ("evidence_summarize", evidence_summarize::templates()),
97        ("graph_plan", graph_plan::templates()),
98        ("repository_explore", repository_explore::templates()),
99        ("session_bootstrap", session_bootstrap::templates()),
100    ];
101    let mut templates = Vec::new();
102    for (stage, list) in stages {
103        for template in list {
104            templates.push((stage, template));
105        }
106    }
107    templates
108}
109
110/// One platform stage's rendered sections, ready to compile for any route.
111#[derive(Debug, Clone)]
112pub struct PlatformStage {
113    pub stage: PromptStage,
114    /// The stage's declared section separator (identity-bearing for the
115    /// migrated literals).
116    pub separator: &'static str,
117    pub sections: Vec<RenderedSection>,
118}
119
120impl PlatformStage {
121    /// Compile this stage for one resolved route through the SDK compiler —
122    /// the only composer. The stage separator joins system sections under a
123    /// single-slot dialect, preserving the migrated literals' bytes.
124    pub fn compile(
125        &self,
126        route: &PromptRoute,
127        dialect: &ModelDialect,
128        tool_spec_hash: &str,
129    ) -> Result<CompiledPromptProgram> {
130        let mut composition = StageComposition::new(self.stage).system_separator(self.separator);
131        for section in &self.sections {
132            composition =
133                composition.resolved(section.clone(), perspt_sdk::prompt::OverrideOrigin::Base);
134        }
135        composition.compile(route, dialect, tool_spec_hash)
136    }
137}
138
139/// Compile one call's two programs — the platform stage and the domain's
140/// same-stage sections (possibly none) — into the invocation the ledger
141/// records (spec: "Each model call compiles two programs").
142pub fn compile_invocation(
143    stage: &PlatformStage,
144    domain_sections: &[RenderedSection],
145    route: &PromptRoute,
146    dialect: &ModelDialect,
147    tool_spec_hash: &str,
148) -> Result<perspt_sdk::prompt::CompiledPromptInvocation> {
149    let platform = stage.compile(route, dialect, tool_spec_hash)?;
150    let mut composition = StageComposition::new(stage.stage);
151    for section in domain_sections {
152        composition =
153            composition.resolved(section.clone(), perspt_sdk::prompt::OverrideOrigin::Base);
154    }
155    let domain = composition.compile(route, dialect, tool_spec_hash)?;
156    Ok(perspt_sdk::prompt::CompiledPromptInvocation::new(
157        platform, domain,
158    ))
159}
160
161/// The compiled platform envelope, one stage builder per actor.
162#[derive(Debug)]
163pub struct PlatformPromptLibrary;
164
165impl PlatformPromptLibrary {
166    /// The worker's session bootstrap. The domain is a variable — the old
167    /// literal hardcoded "coding" for every domain.
168    pub fn session_bootstrap(
169        domain_id: &str,
170        overrides: &SectionOverrides,
171    ) -> Result<PlatformStage> {
172        let role = session_bootstrap::Role {
173            domain_id: perspt_sdk::prompt::BoundedText::new(domain_id)?,
174        };
175        Ok(PlatformStage {
176            stage: PromptStage::SessionBootstrap,
177            separator: session_bootstrap::SEPARATOR,
178            sections: vec![
179                overrides.rendered(session_bootstrap::Role::template(), &role.values())?,
180                overrides.rendered(
181                    session_bootstrap::Governance::template(),
182                    &session_bootstrap::Governance {}.values(),
183                )?,
184            ],
185        })
186    }
187
188    /// The architect's planning turn. The revision shape is rendered from
189    /// the single source of truth beside `PlanSpec`, so the schema shown to
190    /// the model cannot drift from the schema enforced on it.
191    pub fn graph_plan(revision_shape: &str, overrides: &SectionOverrides) -> Result<PlatformStage> {
192        let protocol = graph_plan::UpdateProtocol {
193            revision_shape: perspt_sdk::prompt::BoundedText::new(revision_shape)?,
194        };
195        Ok(PlatformStage {
196            stage: PromptStage::GraphPlan,
197            separator: graph_plan::SEPARATOR,
198            sections: vec![
199                overrides.rendered(graph_plan::Role::template(), &graph_plan::Role {}.values())?,
200                overrides.rendered(graph_plan::UpdateProtocol::template(), &protocol.values())?,
201            ],
202        })
203    }
204
205    pub fn repository_explore(overrides: &SectionOverrides) -> Result<PlatformStage> {
206        Ok(PlatformStage {
207            stage: PromptStage::RepositoryExplore,
208            separator: repository_explore::SEPARATOR,
209            sections: vec![
210                overrides.rendered(
211                    repository_explore::Role::template(),
212                    &repository_explore::Role {}.values(),
213                )?,
214                overrides.rendered(
215                    repository_explore::Protocol::template(),
216                    &repository_explore::Protocol {}.values(),
217                )?,
218            ],
219        })
220    }
221
222    pub fn adjudicate(overrides: &SectionOverrides) -> Result<PlatformStage> {
223        Ok(PlatformStage {
224            stage: PromptStage::Adjudicate,
225            separator: adjudicate::SEPARATOR,
226            sections: vec![
227                overrides.rendered(adjudicate::Role::template(), &adjudicate::Role {}.values())?,
228                overrides.rendered(
229                    adjudicate::Protocol::template(),
230                    &adjudicate::Protocol {}.values(),
231                )?,
232            ],
233        })
234    }
235
236    pub fn evidence_summarize(overrides: &SectionOverrides) -> Result<PlatformStage> {
237        Ok(PlatformStage {
238            stage: PromptStage::EvidenceSummarize,
239            separator: evidence_summarize::SEPARATOR,
240            sections: vec![
241                overrides.rendered(
242                    evidence_summarize::Role::template(),
243                    &evidence_summarize::Role {}.values(),
244                )?,
245                overrides.rendered(
246                    evidence_summarize::Protocol::template(),
247                    &evidence_summarize::Protocol {}.values(),
248                )?,
249            ],
250        })
251    }
252
253    /// The library manifest, for the session-provenance digest.
254    pub fn manifest() -> PromptManifest {
255        let mut entries = Vec::new();
256        for (stage, template) in compiled_platform_templates() {
257            entries.push(ManifestEntry {
258                id: PromptSectionId(template.schema.id.0.clone()),
259                version: PromptSectionVersion(template.schema.version.0),
260                stage: stage.to_string(),
261                role: match template.schema.role {
262                    PromptMessageRole::System => "system".into(),
263                    PromptMessageRole::User => "user".into(),
264                },
265                required: template.schema.required,
266                priority: template.schema.priority,
267                max_bytes: template.schema.max_bytes,
268                owner: "perspt-core".into(),
269                content_hash: template.content_hash.clone(),
270                activation: None,
271                change_record: None,
272            });
273        }
274        PromptManifest { entries }
275    }
276}