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}