Skip to main content

thndrs_lib/core/prompt/
mod.rs

1//! Prompt assembly and context contract.
2//!
3//! Builds a structured [`PromptBundle`] from app state, then lowers it into
4//! Umans Anthropic-compatible messages. The bundle is data, not ad hoc string
5//! concatenation, so it can be inspected, tested, and serialized.
6//!
7//! ## Fragment Ordering
8//!
9//! The system prompt is assembled from named fragments in a fixed order:
10//!
11//! 1. **base_identity** — short `thndrs`-specific identity.
12//! 2. **communication_style** — how to talk to the user.
13//! 3. **action_model** — when to act, explore, or ask.
14//! 4. **edit_guidance** — exact-edit and write-tool behavior.
15//! 5. **action_safety** — tool boundaries, workspace containment, no shell.
16//! 6. **self_knowledge** — how to answer questions about `thndrs`.
17//! 7. **web_source_guidance** — when and how to use web tools.
18//! 8. Environment metadata — cwd, model, search mode, rounded date.
19//! 9. Self-knowledge snapshot — docs map and compact runtime state, plus a
20//!    compact context dashboard when a context ledger is attached.
21//! 10. Project context — loaded `AGENTS.md` text (below policy and user
22//!     instructions), **or** the selected context projection
23//!     (`<selected_context>`) from the attached [`ContextLedger`] when one is
24//!     present. The ledger path renders selected instructions,
25//!     compaction summaries, and pinned handles.
26//! 11. Tool catalog — provider-native schemas for local tools.
27//! 12. Transcript tail — projected model-visible entries (selected by the
28//!     context policy when a ledger is attached).
29//! 13. User turn — current prompt text.
30
31#[cfg(test)]
32mod tests;
33
34use std::path::Path;
35
36use crate::app::Entry;
37use crate::app::ToolStatus;
38use crate::cli::WebSearchMode;
39use crate::context::ContextSource;
40use crate::internals;
41use crate::providers::ProviderMessage;
42use crate::skills;
43use crate::skills::SkillMetadata;
44use crate::tools;
45use crate::tools::ToolDefinition;
46use crate::utils;
47use crate::utils::datetime;
48use thndrs_agent::context::{ContextItem, ContextLedger};
49
50/// Whether the provider supports reusable history / prompt caching for
51/// AGENTS.md content.
52///
53/// Umans does not currently expose explicit reusable-history or prompt-cache
54/// behavior, so the default is [`HistoryReuse::Unavailable`], which always
55/// includes the active size-capped AGENTS.md content.
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
57pub enum HistoryReuse {
58    /// Provider supports reusable history. Full AGENTS.md text is included
59    /// only when its content hash changes; otherwise only metadata is sent.
60    Available,
61    /// Provider does not support reusable history (default). The active
62    /// size-capped AGENTS.md content is always included.
63    #[default]
64    Unavailable,
65}
66
67/// A named prompt fragment — one focused piece of model-visible context.
68///
69/// Fragments are assembled in order into the system prompt. Each fragment owns
70/// a specific concern (identity, communication style, action safety, etc.) so
71/// that prompt assembly stays modular and testable.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct PromptFragment {
74    /// Short label for debugging and testing (e.g. `"base_identity"`).
75    pub name: &'static str,
76    /// The fragment text, included verbatim in the system prompt.
77    pub content: String,
78}
79
80impl PromptFragment {
81    /// Create a fragment from a static name and content string.
82    pub fn new(name: &'static str, content: impl Into<String>) -> Self {
83        PromptFragment { name, content: content.into() }
84    }
85}
86
87/// The structured prompt bundle before provider-specific lowering.
88///
89/// Each field is a separate piece of model context. The [`std::fmt::Display`]
90/// implementation renders the full system prompt text;
91/// [`lower_to_umans_messages`] converts it to Anthropic-compatible messages.
92#[derive(Clone, Debug)]
93pub struct PromptBundle {
94    /// Ordered prompt fragments: base identity, communication style, action
95    /// model, edit guidance, action safety, self-knowledge, web/source guidance.
96    pub fragments: Vec<PromptFragment>,
97    /// Environment: cwd, model, search mode, rounded date.
98    pub environment: EnvironmentMetadata,
99    /// Loaded AGENTS.md context sources.
100    pub project_context: Vec<ContextSource>,
101    /// Tool catalog as provider-native schemas.
102    pub tool_catalog: Vec<ToolDefinition>,
103    /// Available Agent Skills metadata. Full skill instructions are read only
104    /// after activation.
105    pub available_skills: Vec<SkillMetadata>,
106    /// Projected model-visible transcript tail.
107    pub transcript_tail: Vec<Entry>,
108    /// Current user prompt text.
109    pub user_turn: String,
110    /// Whether the provider supports reusable history / prompt caching for
111    /// AGENTS.md content. When [`HistoryReuse::Available`], full text is
112    /// included only when the content hash changes.
113    pub history_reuse: HistoryReuse,
114    /// Content hash of the root AGENTS.md from the previous turn, if any.
115    ///
116    /// Used with [`HistoryReuse::Available`] to skip re-sending unchanged
117    /// AGENTS.md text. `None` on the first turn or when no context is loaded.
118    pub prev_context_hash: Option<u64>,
119    /// Selected context projection from the context selection policy (P7/P8).
120    ///
121    /// When `Some`, the system prompt renders selected project instructions,
122    /// compaction summaries, and pinned handles from the ledger items,
123    /// the transcript tail is taken from ledger-selected transcript context,
124    /// and a compact context dashboard is added to `<thndrs_self_knowledge>`.
125    ///
126    /// When `None`, the legacy rendering path is used (`project_context` plus
127    /// the fixed transcript tail projection), preserving existing behavior.
128    pub context_ledger: Option<ContextLedger>,
129}
130
131impl PromptBundle {
132    /// Build a [`PromptBundle`] from app state and context.
133    pub fn new(
134        cwd: &Path, model: &str, mode: WebSearchMode, context_sources: &[ContextSource], transcript: &[Entry],
135        user_turn: &str,
136    ) -> PromptBundle {
137        PromptBundle::new_with_skills(cwd, model, mode, context_sources, &[], transcript, user_turn)
138    }
139
140    /// Build a [`PromptBundle`] including discovered Agent Skills metadata.
141    pub fn new_with_skills(
142        cwd: &Path, model: &str, mode: WebSearchMode, context_sources: &[ContextSource],
143        available_skills: &[SkillMetadata], transcript: &[Entry], user_turn: &str,
144    ) -> PromptBundle {
145        let tool_catalog = tools::tool_definitions();
146        let transcript_tail = project_transcript_tail(transcript);
147        PromptBundle {
148            fragments: default_fragments(),
149            environment: EnvironmentMetadata::new(cwd, model, mode),
150            project_context: context_sources.to_vec(),
151            tool_catalog,
152            available_skills: available_skills.to_vec(),
153            transcript_tail,
154            user_turn: user_turn.to_string(),
155            history_reuse: HistoryReuse::default(),
156            prev_context_hash: None,
157            context_ledger: None,
158        }
159    }
160
161    /// Replace the bundle's tool catalog with a runtime-specific catalog.
162    pub fn with_tool_catalog(mut self, tool_catalog: Vec<ToolDefinition>) -> Self {
163        self.tool_catalog = tool_catalog;
164        self
165    }
166
167    /// Attach a selected context projection (P7 ledger) so the system prompt
168    /// renders selected instructions, summaries, pins, and transcript
169    /// context from the ledger, and adds a context dashboard to
170    /// `<thndrs_self_knowledge>`.
171    pub fn with_context_ledger(mut self, ledger: ContextLedger) -> Self {
172        self.context_ledger = Some(ledger);
173        self
174    }
175}
176
177/// Environment metadata included in the prompt for cache stability.
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub struct EnvironmentMetadata {
180    /// Workspace root path.
181    pub cwd: String,
182    /// Selected model name.
183    pub model: String,
184    /// Web search mode selected for this turn.
185    pub search_mode: WebSearchMode,
186    /// Rounded current date (YYYY-MM-DD) for cache stability.
187    /// The exact timestamp stays in session JSONL when needed for audit.
188    pub date: String,
189}
190
191impl EnvironmentMetadata {
192    /// Build environment metadata from app state, rounding the date to the
193    /// day for prompt-cache stability.
194    pub fn new(cwd: &Path, model: &str, search_mode: WebSearchMode) -> Self {
195        EnvironmentMetadata {
196            cwd: cwd.display().to_string(),
197            model: model.to_string(),
198            search_mode,
199            date: datetime::rounded_date(),
200        }
201    }
202}
203
204/// Build the default ordered set of prompt fragments.
205///
206/// Each fragment is a separate concern:
207/// 1. **base_identity** — who thndrs is.
208/// 2. **communication_style** — how to talk to the user.
209/// 3. **action_model** — when to act, explore, or ask.
210/// 4. **edit_guidance** — exact-edit and write-tool behavior.
211/// 5. **action_safety** — tool boundaries, workspace containment, no shell.
212/// 6. **self_knowledge** — how to answer questions about `thndrs`.
213/// 7. **web_source_guidance** — when and how to use web tools.
214pub fn default_fragments() -> Vec<PromptFragment> {
215    vec![
216        PromptFragment::new("base_identity", include_str!("fragments/base_identity.xml")),
217        PromptFragment::new("communication_style", include_str!("fragments/communication_style.xml")),
218        PromptFragment::new("action_model", include_str!("fragments/action_model.xml")),
219        PromptFragment::new("edit_guidance", include_str!("fragments/edit_guidance.xml")),
220        PromptFragment::new("action_safety", include_str!("fragments/action_safety.xml")),
221        PromptFragment::new("self_knowledge", include_str!("fragments/self_knowledge.xml")),
222        PromptFragment::new("web_source_guidance", include_str!("fragments/web_source_guidance.xml")),
223    ]
224}
225
226/// Render the system prompt text from the bundle.
227///
228/// Assembles the ordered fragments, environment metadata, and project context
229/// in the correct precedence order. Tool catalog and transcript tail are
230/// rendered as separate message blocks during lowering.
231///
232/// 1. Base identity
233/// 2. Communication style
234/// 3. Action model
235/// 4. Edit guidance
236/// 5. Action safety
237/// 6. Self-knowledge
238/// 7. Web/source guidance
239/// 8. Environment metadata.
240/// 9. Self-knowledge snapshot.
241/// 10. Project context (AGENTS.md) — below harness policy and user instructions.
242///
243/// ## AGENTS.md inclusion
244///
245/// When [`HistoryReuse::Available`] and the current content hash matches
246/// `prev_context_hash`, the full AGENTS.md text is omitted — only a metadata
247/// stub (path, hash) is included, since the provider already has the cached
248/// content. When the hash differs or history reuse is unavailable, the active
249/// size-capped content is always included.
250pub fn render_system_prompt(bundle: &PromptBundle) -> String {
251    let mut parts: Vec<String> = bundle.fragments.iter().map(|f| f.content.clone()).collect();
252
253    parts.push(format!(
254        r#"<environment>
255  <workspace><![CDATA[{}]]></workspace>
256  <model><![CDATA[{}]]></model>
257  <search>{}</search>
258  <date>{}</date>
259</environment>"#,
260        cdata(&bundle.environment.cwd),
261        cdata(&bundle.environment.model),
262        bundle.environment.search_mode.label(),
263        bundle.environment.date
264    ));
265
266    let snapshot: internals::SelfKnowledgeSnapshot = bundle.into();
267    parts.push(snapshot.render_model_visible());
268
269    if let Some(ledger) = &bundle.context_ledger {
270        let projection = render_context_projection(ledger);
271        if !projection.is_empty() {
272            parts.push(projection);
273        }
274    } else if !bundle.project_context.is_empty() {
275        parts.push(render_legacy_project_context(bundle));
276    }
277
278    let available_skills = skills::format_available_skills(&bundle.available_skills);
279    if !available_skills.is_empty() {
280        parts.push(available_skills);
281    }
282
283    parts.join("\n\n")
284}
285
286/// Render the legacy `<project_context>` block from `bundle.project_context`.
287///
288/// Used when no context ledger is attached. Preserves the existing AGENTS.md
289/// rendering including history-reuse text omission.
290fn render_legacy_project_context(bundle: &PromptBundle) -> String {
291    let mut context_lines = vec!["<project_context>".to_string()];
292    for source in &bundle.project_context {
293        let text_unchanged =
294            bundle.history_reuse == HistoryReuse::Available && bundle.prev_context_hash == Some(source.content_hash);
295
296        context_lines.push("  <source>".to_string());
297        context_lines.push(format!(
298            "    <path><![CDATA[{}]]></path>",
299            cdata(&source.path.display().to_string())
300        ));
301        context_lines.push(format!("    <scope><![CDATA[{}]]></scope>", cdata(&source.scope)));
302        context_lines.push(format!("    <hash>{}</hash>", source.content_hash));
303        context_lines.push(format!("    <truncated>{}</truncated>", source.truncated));
304
305        if text_unchanged {
306            context_lines.push("    <status>unchanged, text omitted</status>".to_string());
307        } else {
308            context_lines.push(format!("    <content><![CDATA[{}]]></content>", cdata(&source.content)));
309        }
310        context_lines.push("  </source>".to_string());
311    }
312    context_lines.push("</project_context>".to_string());
313    context_lines.join("\n")
314}
315
316/// Render the selected context projection from a [`ContextLedger`] into a
317/// single `<selected_context>` block.
318fn render_context_projection(ledger: &ContextLedger) -> String {
319    use thndrs_agent::context::ContextItemKind as Kind;
320
321    let rendered: Vec<&ContextItem> = ledger
322        .items
323        .iter()
324        .filter(|item| {
325            item.visibility.is_rendered()
326                && matches!(
327                    item.kind,
328                    Kind::ProjectInstruction | Kind::Summary | Kind::PinnedFile | Kind::Skill
329                )
330        })
331        .collect();
332    if rendered.is_empty() {
333        return String::new();
334    }
335
336    let mut out = String::from("<selected_context>\n");
337
338    for item in &rendered {
339        match item.kind {
340            Kind::ProjectInstruction => {
341                out.push_str("  <instruction>\n");
342                push_item_meta(&mut out, 4, item);
343                if let Some(content) = item.content.as_deref() {
344                    out.push_str(&format!("    <content><![CDATA[{}]]></content>\n", cdata(content)));
345                }
346                out.push_str("  </instruction>\n");
347            }
348            Kind::Summary => {
349                out.push_str("  <compaction_summary>\n");
350                push_item_meta(&mut out, 4, item);
351                if let Some(content) = item.content.as_deref() {
352                    out.push_str(&format!("    <content><![CDATA[{}]]></content>\n", cdata(content)));
353                }
354                out.push_str("  </compaction_summary>\n");
355            }
356            Kind::PinnedFile => {
357                out.push_str("  <pinned_handle>\n");
358                push_item_meta(&mut out, 4, item);
359                out.push_str("  </pinned_handle>\n");
360            }
361            Kind::Skill => {
362                out.push_str("  <skill>\n");
363                push_item_meta(&mut out, 4, item);
364                out.push_str("  </skill>\n");
365            }
366            _ => {}
367        }
368    }
369
370    out.push_str("</selected_context>");
371    out
372}
373
374/// Append metadata (id, scope, path, hash) for a rendered context item.
375fn push_item_meta(out: &mut String, indent: usize, item: &ContextItem) {
376    let pad = " ".repeat(indent);
377    out.push_str(&format!("{pad}<id>{}</id>\n", utils::escape_xml(&item.id)));
378    out.push_str(&format!("{pad}<label>{}</label>\n", utils::escape_xml(&item.label)));
379    out.push_str(&format!("{pad}<scope>{}</scope>\n", utils::escape_xml(&item.scope)));
380    if let Some(path) = item.source_path.as_ref() {
381        let path_str = path.display().to_string();
382        out.push_str(&format!("{pad}<path>{}</path>\n", utils::escape_xml(&path_str)));
383    }
384    if let Some(hash) = item.content_hash {
385        out.push_str(&format!("{pad}<hash>{hash}</hash>\n"));
386    }
387}
388
389/// Lower a [`PromptBundle`] into Umans Anthropic-compatible messages.
390///
391/// The first message is a `user` message containing the system prompt (base +
392/// policy + environment + project context). The transcript tail follows as
393/// alternating user/assistant messages.
394///
395/// The final message is the current user turn.
396pub fn lower_to_umans_messages(bundle: &PromptBundle) -> Vec<ProviderMessage> {
397    let mut messages = vec![ProviderMessage::user(&render_system_prompt(bundle))];
398
399    for entry in &bundle.transcript_tail {
400        match entry {
401            Entry::User { text } => messages.push(ProviderMessage::user(text)),
402            Entry::Agent { text, streaming: false, .. } => messages.push(ProviderMessage::assistant(text)),
403            Entry::Reasoning { text, streaming: false, .. } => messages.push(ProviderMessage::assistant(text)),
404            Entry::Tool { name, output, status, .. } if *status != ToolStatus::Running => {
405                // Session transcript entries currently retain their bounded
406                // display lines for compatibility. Lower them through the
407                // provider-neutral model projection so this boundary is
408                // ready for independently persisted projections.
409                let model_output = tools::ToolOutput::ok(name, output.clone()).model.lines;
410                messages.push(ProviderMessage::user(
411                    &(match model_output.is_empty() {
412                        true => format!("[tool: {name} — no output]"),
413                        false => format!("[tool: {name}]\n{}", model_output.join("\n")),
414                    }),
415                ))
416            }
417            _ => (),
418        }
419    }
420
421    if !bundle.user_turn.is_empty() {
422        messages.push(ProviderMessage::user(&bundle.user_turn));
423    }
424
425    messages
426}
427
428/// Render the tool catalog as a compact JSON schema block for the system prompt.
429///
430/// Uses Anthropic-compatible tool definition format: name, description,
431/// input_schema. Delegates to the shared [`tools::tool_catalog_schemas`]
432/// so the prompt-bundle view and the provider request body stay in sync.
433pub fn render_tool_catalog(bundle: &PromptBundle) -> serde_json::Value {
434    tools::tool_catalog_schemas(&bundle.tool_catalog)
435}
436
437/// Project the model-visible transcript tail from the full UI transcript.
438///
439/// Only finalized `User`, `Assistant`, `Reasoning`, and `Tool` entries reach the model.
440fn project_transcript_tail(transcript: &[Entry]) -> Vec<Entry> {
441    transcript
442        .iter()
443        .rev()
444        .take(20)
445        .filter(|e| match e {
446            Entry::User { .. } => true,
447            Entry::Agent { streaming: false, .. } => true,
448            Entry::Reasoning { streaming: false, .. } => true,
449            Entry::Tool { status, .. } => *status != ToolStatus::Running,
450            _ => false,
451        })
452        .cloned()
453        .collect::<Vec<_>>()
454        .into_iter()
455        .rev()
456        .collect()
457}
458
459fn cdata(text: &str) -> String {
460    text.replace("]]>", "]]]]><![CDATA[>")
461}