1mod history;
2
3use std::sync::{Arc, RwLock};
4
5#[cfg(test)]
6use lash_core::llm::types::LlmContentBlock;
7use lash_core::llm::types::{LlmMessage, LlmRole, LlmToolChoice};
8use lash_core::sansio::ContextProjector;
9use lash_core::{
10 LlmRequest, ProjectorContext, PromptContribution, PromptUsage, ProtocolBuildInput,
11 TurnDriverConfig, TurnDriverPreamble,
12};
13use lash_rlm_types::{RlmCreateExtras, RlmFinalAnswerFormat, RlmTermination};
14
15#[cfg(test)]
16use crate::projection::{rlm_history_projection, rlm_protocol_event};
17use crate::rlm_support::decode_rlm_options;
18
19use history::{RlmHistoryRenderInput, build_rlm_history_messages_from_turn};
20#[cfg(test)]
21use history::{
22 RlmHistoryTestRenderInput, append_entry_image_blocks, build_rlm_history_messages,
23 render_history_prompt,
24};
25
26pub type SharedPromptUsage = Arc<RwLock<Option<PromptUsage>>>;
32
33#[derive(Clone)]
34pub struct RlmProjectorConfig {
35 pub max_output_chars: usize,
36 pub max_budget_tokens: Option<usize>,
37 pub last_prompt_usage: SharedPromptUsage,
38 pub prompt_features: crate::protocol::RlmPromptFeatures,
39}
40
41impl Default for RlmProjectorConfig {
42 fn default() -> Self {
43 Self {
44 max_output_chars: 10_000,
45 max_budget_tokens: None,
46 last_prompt_usage: Arc::new(RwLock::new(None)),
47 prompt_features: crate::protocol::RlmPromptFeatures::default(),
48 }
49 }
50}
51
52pub fn build_rlm_preamble(
53 input: ProtocolBuildInput,
54 config: RlmProjectorConfig,
55) -> TurnDriverPreamble {
56 let tool_surface = input.tool_surface.as_ref();
57 let omitted_tool_count = tool_surface.omitted_tool_count();
58 let tool_names = tool_surface.tool_names();
59 let tool_names_fingerprint = tool_surface.tool_names_fingerprint();
60 let mut prompt_contributions = Vec::new();
61
62 let tool_docs = tool_surface.prompt_tool_docs();
63 if !tool_docs.trim().is_empty() {
64 prompt_contributions.push(PromptContribution::execution("Showcased Tools", tool_docs));
65 }
66 prompt_contributions.extend(input.extra_prompt_contributions);
67
68 TurnDriverPreamble {
69 config: TurnDriverConfig {
70 protocol: Arc::new(crate::protocol::RlmDriver),
71 projector: Arc::new(RlmContextProjector {
72 max_output_chars: config.max_output_chars,
73 max_budget_tokens: config.max_budget_tokens,
74 last_prompt_usage: config.last_prompt_usage,
75 }),
76 sync_execution_surface: true,
77 turn_limit_final_message: Arc::new(crate::protocol::turn_limit_final_message),
78 },
79 tool_specs: Arc::new(Vec::new()),
80 tool_names,
81 tool_names_fingerprint,
82 omitted_tool_count,
83 execution_prompt: Arc::from(crate::protocol::rlm_execution_section_for_surface(
84 config.prompt_features,
85 &input.lashlang_surface,
86 )),
87 prompt_contributions,
88 }
89}
90
91#[cfg(test)]
92mod catalogue_tests {
93 use super::*;
94 use lash_core::{ToolActivation, ToolAvailabilityConfig, ToolScheduling};
95
96 fn tool(name: &str) -> lash_core::ToolDefinition {
97 lash_core::ToolDefinition::raw(
98 format!("tool:{name}"),
99 name,
100 format!("Tool {name}"),
101 serde_json::json!({
102 "type": "object",
103 "properties": { "query": { "type": "string" } },
104 "required": ["query"]
105 }),
106 serde_json::json!({ "type": "string" }),
107 )
108 .with_availability(ToolAvailabilityConfig::showcased())
109 .with_activation(ToolActivation::Always)
110 .with_scheduling(ToolScheduling::Parallel)
111 }
112
113 #[test]
114 fn rlm_preamble_uses_resolved_tool_surface_without_search_tool_special_cases() {
115 let definitions = vec![tool("search_tools"), tool("grep")];
116 let contracts = definitions
117 .iter()
118 .map(|tool| (tool.name().to_string(), Arc::new(tool.contract())))
119 .collect();
120 let surface = lash_core::ToolSurface::from_tools(
121 definitions
122 .into_iter()
123 .map(|tool| tool.manifest())
124 .collect(),
125 contracts,
126 );
127
128 let preamble = build_rlm_preamble(
129 lash_core::ProtocolBuildInput {
130 tool_surface: Arc::new(surface),
131 lashlang_surface: lashlang::LashlangSurface::new(
132 lashlang::ResourceCatalog::tool_default(["search_tools", "grep"]),
133 lashlang::LashlangAbilities::all(),
134 ),
135 extra_prompt_contributions: Vec::new(),
136 },
137 RlmProjectorConfig::default(),
138 );
139
140 assert_eq!(preamble.omitted_tool_count, 0);
141 assert_eq!(preamble.tool_names.as_ref(), &vec!["search_tools", "grep"]);
142 let prompt = preamble
143 .prompt_contributions
144 .iter()
145 .map(|contribution| contribution.content.as_ref())
146 .collect::<Vec<_>>()
147 .join("\n");
148 assert!(prompt.contains("search_tools"));
149 assert!(prompt.contains("grep"));
150 }
151
152 #[test]
153 fn rlm_preamble_uses_lashlang_surface_abilities() {
154 let definitions = vec![tool("grep")];
155 let contracts = definitions
156 .iter()
157 .map(|tool| (tool.name().to_string(), Arc::new(tool.contract())))
158 .collect();
159 let surface = lash_core::ToolSurface::from_tools(
160 definitions
161 .into_iter()
162 .map(|tool| tool.manifest())
163 .collect(),
164 contracts,
165 );
166
167 let preamble = build_rlm_preamble(
168 lash_core::ProtocolBuildInput {
169 tool_surface: Arc::new(surface),
170 lashlang_surface: lashlang::LashlangSurface::new(
171 lashlang::ResourceCatalog::tool_default(["grep"]),
172 lashlang::LashlangAbilities::default(),
173 ),
174 extra_prompt_contributions: Vec::new(),
175 },
176 RlmProjectorConfig::default(),
177 );
178
179 assert!(!preamble.execution_prompt.contains("process name"));
180 assert!(!preamble.execution_prompt.contains("sleep for"));
181 assert!(preamble.execution_prompt.contains("Module operations"));
182 }
183
184 #[test]
185 fn finish_finalization_prompt_defaults_to_submit_guidance() {
186 let prompt = rlm_finalization_prompt(&RlmTermination::default());
187
188 assert!(prompt.contains("submit <value>"));
189 }
190
191 #[test]
192 fn prose_or_submit_finalization_prompt_allows_direct_prose() {
193 let prompt = rlm_finalization_prompt(&RlmTermination::ProseOrSubmit);
194
195 assert!(prompt.contains("Either finish your turn with prose only"));
196 assert!(prompt.contains("or use `submit` in lashlang"));
197 assert!(prompt.contains("Do not duplicate"));
198 }
199}
200
201struct RlmContextProjector {
202 max_output_chars: usize,
203 max_budget_tokens: Option<usize>,
204 last_prompt_usage: SharedPromptUsage,
205}
206
207impl ContextProjector<lash_core::HostTurnProtocol> for RlmContextProjector {
208 fn project(&self, ctx: ProjectorContext<'_>) -> Arc<LlmRequest> {
209 let options = decode_rlm_options(&ctx.config.termination)
210 .expect("RLM turn options are validated before prompt projection");
211 let finalization = rlm_finalization_prompt(&options.termination);
212 let required_output = required_output_block(&options.termination);
213 let final_answer_format = final_answer_format_prompt(&options);
214 let budget_suffix = self.last_prompt_usage.read().ok().and_then(|guard| {
215 crate::rlm_support::format_budget_suffix(
216 ctx.protocol_iteration + 1,
217 guard.as_ref(),
218 self.max_budget_tokens,
219 )
220 });
221
222 let mut messages = Vec::new();
223 if !ctx.config.system_prompt.trim().is_empty() {
224 messages.push(LlmMessage::text(
225 LlmRole::System,
226 Arc::clone(&ctx.config.system_prompt),
227 ));
228 }
229 let mut attachments = Vec::new();
230 messages.extend(build_rlm_history_messages_from_turn(
231 RlmHistoryRenderInput {
232 events: ctx.events,
233 turn_messages: ctx.messages,
234 turn_causes: ctx.turn_causes,
235 max_output_chars: self.max_output_chars,
236 protocol_iteration: ctx.protocol_iteration + 1,
237 finalization,
238 required_output: required_output.as_deref(),
239 final_answer_format: final_answer_format.as_deref(),
240 budget_suffix: budget_suffix.as_deref(),
241 },
242 &mut attachments,
243 ));
244
245 Arc::new(LlmRequest {
246 model: ctx.config.model.clone(),
247 messages,
248 attachments,
249 tools: Arc::new(Vec::new()),
250 tool_choice: LlmToolChoice::None,
251 model_variant: ctx.config.model_variant.clone(),
252 session_id: ctx.config.run_session_id.clone(),
253 output_spec: None,
254 stream_events: None,
255 generation: ctx.config.generation.clone(),
256 provider_trace: None,
257 })
258 }
259}
260
261fn required_output_block(termination: &RlmTermination) -> Option<String> {
262 match termination {
263 RlmTermination::SubmitRequired {
264 schema: Some(schema),
265 } => Some(render_value_schema_contract(schema)),
266 _ => None,
267 }
268}
269
270fn final_answer_format_prompt(options: &RlmCreateExtras) -> Option<String> {
271 if matches!(
272 options.termination,
273 RlmTermination::SubmitRequired { schema: Some(_) }
274 ) {
275 return None;
276 }
277 match options.final_answer_format.as_ref()? {
278 RlmFinalAnswerFormat::Markdown => Some(
279 "When using `submit`, submit a nicely formatted Markdown string, not a raw record/list/tool-result value."
280 .to_string(),
281 ),
282 RlmFinalAnswerFormat::Custom { guidance } => {
283 let guidance = guidance.trim();
284 (!guidance.is_empty()).then(|| guidance.to_string())
285 }
286 RlmFinalAnswerFormat::RawSubmitValue => None,
287 }
288}
289
290fn render_value_schema_contract(schema: &serde_json::Value) -> String {
291 let input_contract = lash_core::ToolDefinition::raw(
292 "tool:submit",
293 "submit",
294 "",
295 schema.clone(),
296 serde_json::json!({}),
297 )
298 .compact_contract();
299
300 if input_contract.parameters.is_empty() {
301 return lash_core::ToolDefinition::raw(
302 "tool:submit",
303 "submit",
304 "",
305 lash_core::ToolDefinition::default_input_schema(),
306 schema.clone(),
307 )
308 .compact_contract()
309 .returns;
310 }
311
312 let head = format!(
313 "{{ {} }}",
314 input_contract
315 .parameters
316 .iter()
317 .filter_map(|value| value.get("signature").and_then(serde_json::Value::as_str))
318 .collect::<Vec<_>>()
319 .join(", ")
320 );
321 let lines = input_contract
322 .parameters
323 .iter()
324 .filter_map(compact_doc_line)
325 .collect::<Vec<_>>();
326
327 if lines.is_empty() {
328 head
329 } else {
330 format!("{head}\nFields:\n{}", lines.join("\n"))
331 }
332}
333
334fn compact_doc_line(value: &serde_json::Value) -> Option<String> {
335 let signature = value.get("signature")?.as_str()?.trim();
336 if signature.is_empty() {
337 return None;
338 }
339 let description = value
340 .get("description")
341 .and_then(serde_json::Value::as_str)
342 .map(str::trim)
343 .filter(|value| !value.is_empty());
344 Some(match description {
345 Some(description) => format!("- `{signature}` — {description}"),
346 None => format!("- `{signature}`"),
347 })
348}
349
350fn rlm_finalization_prompt(termination: &RlmTermination) -> &'static str {
351 match termination {
352 RlmTermination::SubmitRequired { .. } => {
353 "The turn must finish through `submit <value>`. Prose alone does not end the turn."
354 }
355 RlmTermination::ProseOrSubmit => {
356 "Either finish your turn with prose only, without a lashlang block, or use `submit` in lashlang. Do not duplicate the submitted answer in prose."
357 }
358 }
359}
360
361impl RlmContextProjector {
362 #[cfg(test)]
363 fn format_history(&self, projection: &lash_core::ChronologicalProjection) -> String {
364 let history = rlm_history_projection(projection);
365 render_history_prompt(history.history(), self.max_output_chars)
366 }
367}
368
369#[cfg(test)]
370fn projection_from_events(
371 events: &[lash_core::SessionEventRecord],
372) -> lash_core::ChronologicalProjection {
373 lash_core::ChronologicalProjection::from_turn_view(
374 events,
375 &lash_core::MessageSequence::default(),
376 )
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use lash_core::session_model::{
383 ConversationRecord, MessageRole, Part, PartKind, PruneState, SessionEventRecord,
384 };
385 use lash_rlm_types::{RlmProtocolEvent, RlmTrajectoryEntry};
386
387 fn user_event(id: &str, text: &str) -> SessionEventRecord {
388 SessionEventRecord::Conversation(ConversationRecord {
389 id: id.to_string(),
390 role: MessageRole::User,
391 parts: vec![Part {
392 id: format!("{id}.p0"),
393 kind: PartKind::Text,
394 content: text.to_string(),
395 attachment: None,
396 tool_call_id: None,
397 tool_name: None,
398 tool_replay: None,
399 prune_state: PruneState::Intact,
400 reasoning_meta: None,
401 response_meta: None,
402 }]
403 .into(),
404 origin: None,
405 })
406 }
407
408 fn step_event(protocol_iteration: usize, code: &str, output: &str) -> SessionEventRecord {
409 SessionEventRecord::Protocol(rlm_protocol_event(RlmProtocolEvent::RlmTrajectoryEntry(
410 RlmTrajectoryEntry {
411 id: format!("rlm_step_{protocol_iteration}"),
412 protocol_iteration,
413 reasoning: "thinking".to_string(),
414 code: code.to_string(),
415 output: if output.is_empty() {
416 Vec::new()
417 } else {
418 vec![output.to_string()]
419 },
420 images: Vec::new(),
421 error: None,
422 final_output: None,
423 },
424 )))
425 }
426
427 fn projector(max_output_chars: usize) -> RlmContextProjector {
428 RlmContextProjector {
429 max_output_chars,
430 max_budget_tokens: None,
431 last_prompt_usage: Arc::new(RwLock::new(None)),
432 }
433 }
434
435 #[test]
436 fn chronological_history_renders_messages_and_steps_in_order() {
437 let projector = projector(100);
438 let events = [
439 user_event("u1", "first"),
440 step_event(0, "print 1", "1"),
441 user_event("u2", "second"),
442 step_event(1, "print 2", "2"),
443 ];
444 let history = projector.format_history(&projection_from_events(&events));
445
446 assert!(history.contains("--- history[0] · user message · 5 chars ---\n\nfirst"));
447 assert!(history.contains("--- history[1] · rlm step · protocol_iteration 0 ---"));
448 assert!(history.contains("Code:\n```lashlang\nprint 1\n```"));
449 assert!(history.contains("history[1].output[0] (1 chars):\n1"));
450 assert!(history.contains("--- history[2] · user message · 6 chars ---\n\nsecond"));
451 assert!(history.contains("--- history[3] · rlm step · protocol_iteration 1 ---"));
452 assert!(history.contains("history[3].output[0] (1 chars):\n2"));
453 assert!(!history.contains("\n\nOutput ("));
457 assert!(!history.contains("\n\nTool calls:"));
458 assert!(!history.contains("Task"));
459 assert!(!history.contains("user_input_"));
460 }
461
462 #[test]
463 fn chronological_history_excludes_hidden_tool_events() {
464 let projector = projector(1000);
465 let events = [user_event("u1", "first"), step_event(0, "x = 1", "1")];
466 let history = projector.format_history(&projection_from_events(&events));
467
468 assert!(history.contains("--- history[0] · user message"));
469 assert!(history.contains("--- history[1] · rlm step · protocol_iteration 0 ---"));
470 assert!(!history.contains("tool_call"));
471 }
472
473 #[test]
474 fn long_user_message_gets_full_history_reference() {
475 let projector = projector(10);
476 let history = projector.format_history(&projection_from_events(&[user_event(
477 "u1",
478 "abcdefghijklmnopqrstuvwxyz",
479 )]));
480
481 assert!(history.contains("full: history[0].content"));
482 assert!(history.contains("... (16 characters omitted) ..."));
483 assert!(!history.contains("user_input_"));
484 }
485
486 #[test]
487 fn truncated_rlm_step_output_emits_full_reference() {
488 let projector = projector(10);
494 let history = projector.format_history(&projection_from_events(&[step_event(
495 0,
496 "print big",
497 "abcdefghijklmnopqrstuvwxyz",
498 )]));
499
500 assert!(history.contains("full: history[0].output[0]"));
501 assert!(history.contains("... (16 characters omitted) ..."));
502 }
503
504 #[test]
505 fn plugin_origin_is_not_rendered_in_history() {
506 let projector = projector(100);
507 let event = SessionEventRecord::Conversation(ConversationRecord {
508 id: "plugin".to_string(),
509 role: MessageRole::User,
510 parts: vec![Part {
511 id: "plugin.p0".to_string(),
512 kind: PartKind::Text,
513 content: "synthetic plugin message".to_string(),
514 attachment: None,
515 tool_call_id: None,
516 tool_name: None,
517 tool_replay: None,
518 prune_state: PruneState::Intact,
519 reasoning_meta: None,
520 response_meta: None,
521 }]
522 .into(),
523 origin: Some(lash_core::MessageOrigin::Plugin {
524 plugin_id: "test".to_string(),
525 transient: false,
526 }),
527 });
528
529 let history = projector.format_history(&projection_from_events(&[event]));
530 assert!(history.contains("--- history[0] · user message"));
531 assert!(history.contains("synthetic plugin message"));
532 assert!(!history.contains("from plugin"));
533 assert!(!history.contains("test"));
534 }
535
536 #[test]
537 fn process_wake_history_renders_as_chronological_event_context() {
538 let projector = projector(1000);
539 let event = SessionEventRecord::Conversation(ConversationRecord {
540 id: "wake:abc".to_string(),
541 role: MessageRole::Event,
542 parts: vec![Part {
543 id: "wake:abc.p0".to_string(),
544 kind: PartKind::Text,
545 content: "Background process wake\nProcess: process-1\nEvent: process.wake #7\nWake input:\nblue button pressed".to_string(),
546 attachment: None,
547 tool_call_id: None,
548 tool_name: None,
549 tool_replay: None,
550 prune_state: PruneState::Intact,
551 reasoning_meta: None,
552 response_meta: None,
553 }]
554 .into(),
555 origin: Some(lash_core::MessageOrigin::Process {
556 process_id: "process-1".to_string(),
557 event_type: "process.wake".to_string(),
558 sequence: 7,
559 wake_id: Some("wake:abc".to_string()),
560 caused_by: None,
561 }),
562 });
563 let projection = projection_from_events(&[event]);
564 let mut attachments = Vec::new();
565
566 let messages = build_rlm_history_messages(
567 RlmHistoryTestRenderInput {
568 projection: &projection,
569 max_output_chars: 1000,
570 protocol_iteration: 1,
571 finalization: rlm_finalization_prompt(&RlmTermination::default()),
572 required_output: None,
573 final_answer_format: None,
574 budget_suffix: None,
575 },
576 &mut attachments,
577 );
578 let history = projector.format_history(&projection);
579
580 assert!(history.contains("--- history[0] · event message"));
581 assert!(history.contains("Background process wake"));
582 assert!(history.contains("blue button pressed"));
583 assert!(!history.contains("system message"));
584 assert!(matches!(messages[0].role, LlmRole::User));
585 }
586
587 #[test]
588 fn active_turn_causes_render_in_current_turn_events_without_history_duplication() {
589 let cause = lash_core::TurnCause {
590 id: "wake:abc".to_string(),
591 event_type: "process.wake".to_string(),
592 origin: lash_core::MessageOrigin::Process {
593 process_id: "process-1".to_string(),
594 event_type: "process.wake".to_string(),
595 sequence: 7,
596 wake_id: Some("wake:abc".to_string()),
597 caused_by: None,
598 },
599 text: "Background process wake\nProcess: process-1\nEvent: process.wake #7\nWake input:\nblue button pressed".to_string(),
600 };
601 let event_message = cause.to_event_message();
602 let messages = lash_core::MessageSequence::from(vec![event_message]);
603 let mut attachments = Vec::new();
604
605 let rendered = build_rlm_history_messages_from_turn(
606 RlmHistoryRenderInput {
607 events: &[],
608 turn_messages: &messages,
609 turn_causes: std::slice::from_ref(&cause),
610 max_output_chars: 1000,
611 protocol_iteration: 0,
612 finalization: rlm_finalization_prompt(&RlmTermination::default()),
613 required_output: None,
614 final_answer_format: None,
615 budget_suffix: None,
616 },
617 &mut attachments,
618 );
619
620 let combined = rendered
621 .iter()
622 .flat_map(|message| message.blocks.iter())
623 .filter_map(|block| match block {
624 LlmContentBlock::Text { text, .. } => Some(text.as_ref()),
625 _ => None,
626 })
627 .collect::<Vec<_>>()
628 .join("\n");
629 assert!(combined.contains("=== TURN EVENTS ==="));
630 assert!(combined.contains("blue button pressed"));
631 assert!(!combined.contains("--- history[0] · event message"));
632 assert!(rendered.iter().any(|message| {
633 message.role == LlmRole::User
634 && message.blocks.iter().any(|block| {
635 matches!(
636 block,
637 LlmContentBlock::Text { text, .. }
638 if text.contains("=== TURN EVENTS ===")
639 )
640 })
641 }));
642 }
643
644 #[test]
645 fn printed_images_render_as_llm_image_blocks() {
646 let event = SessionEventRecord::Protocol(rlm_protocol_event(
647 RlmProtocolEvent::RlmTrajectoryEntry(RlmTrajectoryEntry {
648 id: "rlm_step_1".to_string(),
649 protocol_iteration: 1,
650 reasoning: String::new(),
651 code: "print img".to_string(),
652 output: vec![r#"{"type":"image","id":"img"}"#.to_string()],
653 images: vec![lash_core::AttachmentRef {
654 id: lash_core::AttachmentId::new("img-ref"),
655 media_type: lash_core::MediaType::Image(lash_core::ImageMediaType::Png),
656 byte_len: 3,
657 width: Some(1),
658 height: Some(1),
659 label: Some("img.png".to_string()),
660 }],
661 error: None,
662 final_output: None,
663 }),
664 ));
665 let mut attachments = Vec::new();
666 let mut blocks = Vec::new();
667
668 let projection = projection_from_events(&[event]);
669 append_entry_image_blocks(
670 projection.entries().first().expect("entry"),
671 &mut attachments,
672 &mut blocks,
673 );
674
675 assert_eq!(attachments.len(), 1);
676 assert_eq!(attachments[0].mime, "image/png");
677 assert!(attachments[0].data.is_empty());
678 assert_eq!(
679 attachments[0]
680 .reference
681 .as_ref()
682 .map(|reference| reference.id.as_str()),
683 Some("img-ref")
684 );
685 assert!(matches!(
686 blocks.as_slice(),
687 [LlmContentBlock::Image { attachment_idx: 0 }]
688 ));
689 }
690
691 #[test]
692 fn rlm_prompt_projects_history_as_chat_messages_with_rolling_cache_breakpoint() {
693 let projection =
694 projection_from_events(&[user_event("u1", "first"), step_event(0, "print 1", "1")]);
695 let mut attachments = Vec::new();
696
697 let messages = build_rlm_history_messages(
698 RlmHistoryTestRenderInput {
699 projection: &projection,
700 max_output_chars: 1000,
701 protocol_iteration: 2,
702 finalization: rlm_finalization_prompt(&RlmTermination::default()),
703 required_output: None,
704 final_answer_format: None,
705 budget_suffix: None,
706 },
707 &mut attachments,
708 );
709
710 assert_eq!(messages.len(), 3);
711 assert!(matches!(messages[0].role, LlmRole::User));
712 assert!(matches!(messages[1].role, LlmRole::Assistant));
713 assert!(matches!(messages[2].role, LlmRole::User));
714 assert!(matches!(
715 messages[0].blocks.first(),
716 Some(LlmContentBlock::Text {
717 text,
718 cache_breakpoint: false,
719 ..
720 }) if text.starts_with("--- history[0] · user message")
721 ));
722 assert!(matches!(
723 messages[1].blocks.first(),
724 Some(LlmContentBlock::Text {
725 text,
726 cache_breakpoint: true,
727 ..
728 }) if text.starts_with("--- history[1] · rlm step")
729 ));
730 assert!(matches!(
731 messages[2].blocks.first(),
732 Some(LlmContentBlock::Text {
733 text,
734 cache_breakpoint: false,
735 ..
736 }) if text.contains("=== CURRENT ITERATION: 2 ===")
737 ));
738 }
739
740 #[test]
741 fn rlm_prompt_renders_required_output_block_when_schema_present() {
742 let projection = projection_from_events(&[user_event("u1", "first")]);
743 let mut attachments = Vec::new();
744 let schema = serde_json::json!({
745 "type": "object",
746 "properties": {
747 "action": { "type": "string", "enum": ["call", "fold"] },
748 "amount": { "type": "integer", "minimum": 0 }
749 },
750 "required": ["action"]
751 });
752
753 let schema_contract = render_value_schema_contract(&schema);
754 let messages = build_rlm_history_messages(
755 RlmHistoryTestRenderInput {
756 projection: &projection,
757 max_output_chars: 1000,
758 protocol_iteration: 1,
759 finalization: "Call submit",
760 required_output: Some(&schema_contract),
761 final_answer_format: None,
762 budget_suffix: None,
763 },
764 &mut attachments,
765 );
766
767 let tail = messages
768 .last()
769 .and_then(|message| message.blocks.first())
770 .and_then(|block| match block {
771 LlmContentBlock::Text { text, .. } => Some(text.as_ref()),
772 _ => None,
773 })
774 .expect("tail block");
775 assert!(tail.contains("=== REQUIRED OUTPUT ==="));
776 assert!(tail.contains("{ action: enum[\"call\", \"fold\"], amount?: int >= 0 }"));
777 assert!(tail.contains("Fields:"));
778 }
779
780 #[test]
781 fn final_answer_format_guidance_renders_markdown_for_unstructured_turns() {
782 let guidance = final_answer_format_prompt(&RlmCreateExtras {
783 termination: RlmTermination::SubmitRequired { schema: None },
784 final_answer_format: Some(RlmFinalAnswerFormat::Markdown),
785 })
786 .expect("markdown guidance");
787
788 assert!(guidance.contains("Markdown string"));
789 assert!(guidance.contains("not a raw record/list/tool-result value"));
790 }
791
792 #[test]
793 fn final_answer_format_guidance_honors_custom_text_and_raw_suppression() {
794 let custom = final_answer_format_prompt(&RlmCreateExtras {
795 termination: RlmTermination::ProseOrSubmit,
796 final_answer_format: Some(RlmFinalAnswerFormat::Custom {
797 guidance: " Submit concise release-note Markdown. ".to_string(),
798 }),
799 })
800 .expect("custom guidance");
801 assert_eq!(custom, "Submit concise release-note Markdown.");
802
803 assert!(
804 final_answer_format_prompt(&RlmCreateExtras {
805 termination: RlmTermination::SubmitRequired { schema: None },
806 final_answer_format: Some(RlmFinalAnswerFormat::RawSubmitValue),
807 })
808 .is_none()
809 );
810 }
811
812 #[test]
813 fn required_output_schema_suppresses_final_answer_format_guidance() {
814 let guidance = final_answer_format_prompt(&RlmCreateExtras {
815 termination: RlmTermination::SubmitRequired {
816 schema: Some(serde_json::json!({ "type": "object" })),
817 },
818 final_answer_format: Some(RlmFinalAnswerFormat::Markdown),
819 });
820
821 assert!(guidance.is_none());
822 }
823
824 #[test]
825 fn render_value_schema_contract_renders_object_shape_with_field_table() {
826 let schema = serde_json::json!({
827 "type": "object",
828 "properties": {
829 "action": { "type": "string", "enum": ["call", "fold"] },
830 "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
831 },
832 "required": ["action"]
833 });
834
835 let rendered = render_value_schema_contract(&schema);
836 let head = rendered.lines().next().expect("at least one line");
837 assert_eq!(
838 head,
839 "{ action: enum[\"call\", \"fold\"], confidence?: float >= 0 <= 1 }"
840 );
841 assert!(rendered.contains("Fields:"));
842 assert!(rendered.contains("- `action: enum[\"call\", \"fold\"]`"));
843 assert!(rendered.contains("- `confidence?: float >= 0 <= 1`"));
844 }
845
846 #[test]
847 fn render_value_schema_contract_falls_back_to_compact_label_for_scalars() {
848 let scalar = serde_json::json!({ "type": "string" });
849 assert_eq!(render_value_schema_contract(&scalar), "str");
850
851 let array = serde_json::json!({ "type": "array", "items": { "type": "integer" } });
852 assert_eq!(render_value_schema_contract(&array), "list[int]");
853
854 let nullable_string = serde_json::json!({ "type": ["string", "null"] });
855 assert_eq!(render_value_schema_contract(&nullable_string), "str | null");
856 }
857
858 #[test]
859 fn incremental_render_extends_cached_prefix_on_subsequent_calls() {
860 let projector = projector(100);
861 let initial = projector.format_history(&projection_from_events(&[
862 user_event("u1", "first"),
863 step_event(0, "print 1", "1"),
864 ]));
865 assert!(initial.contains("--- history[0] · user message"));
866 assert!(initial.contains("--- history[1] · rlm step · protocol_iteration 0 ---"));
867
868 let extended = projector.format_history(&projection_from_events(&[
869 user_event("u1", "first"),
870 step_event(0, "print 1", "1"),
871 user_event("u2", "second"),
872 step_event(1, "print 2", "2"),
873 ]));
874 assert!(extended.starts_with(&initial));
875 assert!(extended.contains("--- history[2] · user message"));
876 assert!(extended.contains("--- history[3] · rlm step · protocol_iteration 1 ---"));
877 }
878}