Skip to main content

lash_protocol_rlm/
driver.rs

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
26/// Cell shared between the RLM protocol plugin's turn-prepare hook (writer)
27/// and the projector (reader). The plugin's hook captures `prompt_usage`
28/// from `TurnTransformContext` each turn and stores it here so the
29/// projector can render the budget suffix into the volatile turn-tail
30/// message — keeping the cached system prefix byte-stable.
31pub 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        // Old combined "Output" + "Tool calls" sections were removed —
454        // each `print` is now its own block, and tool calls are visible
455        // inline in the `code` block above.
456        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        // The render half of the re-fetch contract: a truncated step output
489        // shows a preview plus a `full: history[0].output[0]` handle. The
490        // resolve half — that the handle returns the full untruncated value —
491        // is covered by `history_step_output_resolves_full_untruncated_value`
492        // in projection::context.
493        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}