返回 DeepSeek-TUI-2026
turn.rs
根目录 / crates / tui / src / rlm / turn.rs
1 //! RLM turn loop — paper Algorithm 1 driven over a long-lived Python
2 //! subprocess + stdin/stdout RPC bridge (no HTTP sidecar).
3
4 use std::path::PathBuf;
5 use std::sync::Arc;
6 use std::time::{Duration, Instant};
7
8 use tokio::sync::mpsc;
9 use uuid::Uuid;
10
11 use crate::client::DeepSeekClient;
12 use crate::core::events::Event;
13 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
14 use crate::repl::PythonRuntime;
15
16 use super::bridge::{RlmBridge, RlmLlmClient};
17 use super::prompt::rlm_system_prompt;
18
19 // ---------------------------------------------------------------------------
20 // Constants
21 // ---------------------------------------------------------------------------
22
23 /// Maximum number of RLM iterations before the loop gives up.
24 const MAX_RLM_ITERATIONS: u32 = 25;
25 /// Max consecutive rounds where the model returns no `repl` fence before we
26 /// hard-fail. The paper requires `code → REPL → Final`; anything else is
27 /// not the RLM contract.
28 const MAX_CONSECUTIVE_NO_CODE: u32 = 3;
29 /// Max output tokens for the root LLM — it just needs to generate code.
30 const ROOT_MAX_TOKENS: u32 = 4096;
31 /// Max chars of stdout shown as metadata to the root LLM in next iteration.
32 const STDOUT_METADATA_PREVIEW_LEN: usize = 800;
33 /// Max chars of `context` shown as a preview in the metadata.
34 const PROMPT_PREVIEW_LEN: usize = 500;
35 /// Temperature for root LLM calls.
36 const ROOT_TEMPERATURE: f32 = 0.3;
37 /// Hard wall-clock cap on a whole RLM turn.
38 const TURN_TIMEOUT: Duration = Duration::from_secs(180);
39 /// Bound on conversation history we keep across iterations.
40 const MAX_HISTORY_MESSAGES: usize = 20;
41
42 // ---------------------------------------------------------------------------
43 // Public API
44 // ---------------------------------------------------------------------------
45
46 /// How an RLM turn ended.
47 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48 pub enum RlmTermination {
49 /// `FINAL(value)` was called inside the REPL or `FINAL(...)` appeared
50 /// at the top of the model's response on its own line.
51 Final,
52 /// The model failed to emit a `repl` block for too many rounds in a
53 /// row. The accumulated last response text is surfaced as the answer
54 /// rather than being thrown away.
55 NoCode,
56 /// Iteration cap reached without `FINAL`.
57 Exhausted,
58 /// Hard error — LLM call failed, REPL crashed, timeout.
59 Error,
60 }
61
62 /// Per-round trace entry. Surfaced in the tool result so the user can see
63 /// exactly what the sub-agent did.
64 #[derive(Debug, Clone)]
65 pub struct RlmRoundTrace {
66 pub round: u32,
67 pub code_summary: String,
68 pub stdout_preview: String,
69 pub had_error: bool,
70 pub rpc_count: u32,
71 pub elapsed_ms: u64,
72 }
73
74 /// Result of an RLM turn.
75 #[derive(Debug, Clone)]
76 pub struct RlmTurnResult {
77 pub answer: String,
78 pub iterations: u32,
79 pub duration: Duration,
80 pub error: Option<String>,
81 pub usage: Usage,
82 pub termination: RlmTermination,
83 /// Per-round trace. Empty when the loop never reached the REPL.
84 pub trace: Vec<RlmRoundTrace>,
85 /// Total sub-LLM RPCs made by the sub-agent (sum of `rpc_count` across
86 /// rounds). Useful for verifying that the model engaged with `context`
87 /// rather than answering directly.
88 pub total_rpcs: u32,
89 }
90
91 /// Run a full RLM turn. `prompt` is loaded into the REPL as `context`; it
92 /// never enters the root LLM's window.
93 pub async fn run_rlm_turn(
94 client: &DeepSeekClient,
95 model: String,
96 prompt: String,
97 child_model: String,
98 tx_event: mpsc::Sender<Event>,
99 max_depth: u32,
100 ) -> RlmTurnResult {
101 run_rlm_turn_inner(
102 Arc::new(client.clone()),
103 model,
104 prompt,
105 None,
106 child_model,
107 tx_event,
108 max_depth,
109 )
110 .await
111 }
112
113 /// Variant that also passes a small `root_prompt` (the user-facing task)
114 /// shown to the root LLM each iteration so it remembers its objective.
115 pub async fn run_rlm_turn_with_root(
116 client: &DeepSeekClient,
117 model: String,
118 prompt: String,
119 root_prompt: Option<String>,
120 child_model: String,
121 tx_event: mpsc::Sender<Event>,
122 max_depth: u32,
123 ) -> RlmTurnResult {
124 run_rlm_turn_inner(
125 Arc::new(client.clone()),
126 model,
127 prompt,
128 root_prompt,
129 child_model,
130 tx_event,
131 max_depth,
132 )
133 .await
134 }
135
136 /// Inner entry point — also used by the bridge when it recurses. Returns
137 /// a boxed future to break the recursive opaque-future-type cycle:
138 /// `run_rlm_turn_inner` → `RlmBridge::dispatch` → `run_rlm_turn_inner`.
139 pub(crate) fn run_rlm_turn_inner(
140 client: Arc<dyn RlmLlmClient>,
141 model: String,
142 prompt: String,
143 root_prompt: Option<String>,
144 child_model: String,
145 tx_event: mpsc::Sender<Event>,
146 max_depth: u32,
147 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RlmTurnResult> + Send>> {
148 Box::pin(run_rlm_turn_impl(
149 client,
150 model,
151 prompt,
152 root_prompt,
153 child_model,
154 tx_event,
155 max_depth,
156 ))
157 }
158
159 // ---------------------------------------------------------------------------
160 // Implementation
161 // ---------------------------------------------------------------------------
162
163 async fn run_rlm_turn_impl(
164 client: Arc<dyn RlmLlmClient>,
165 model: String,
166 prompt: String,
167 root_prompt: Option<String>,
168 child_model: String,
169 tx_event: mpsc::Sender<Event>,
170 max_depth: u32,
171 ) -> RlmTurnResult {
172 let start = Instant::now();
173 let mut total_usage = Usage::default();
174 let mut trace: Vec<RlmRoundTrace> = Vec::new();
175 let mut total_rpcs: u32 = 0;
176
177 // 1. Stage `context` to a temp file. The REPL reads it on bootstrap so
178 // the big string never enters the process command line and doesn't
179 // show up in `ps`.
180 let ctx_path = match write_context_file(&prompt) {
181 Ok(p) => p,
182 Err(e) => {
183 return RlmTurnResult {
184 answer: String::new(),
185 iterations: 0,
186 duration: start.elapsed(),
187 error: Some(format!("rlm: failed to stage context: {e}")),
188 usage: total_usage,
189 termination: RlmTermination::Error,
190 trace,
191 total_rpcs,
192 };
193 }
194 };
195
196 // 2. Spawn the long-lived REPL.
197 let mut repl = match PythonRuntime::spawn_with_context(&ctx_path).await {
198 Ok(rt) => rt,
199 Err(e) => {
200 let _ = tokio::fs::remove_file(&ctx_path).await;
201 return RlmTurnResult {
202 answer: String::new(),
203 iterations: 0,
204 duration: start.elapsed(),
205 error: Some(format!("rlm: failed to spawn REPL: {e}")),
206 usage: total_usage,
207 termination: RlmTermination::Error,
208 trace,
209 total_rpcs,
210 };
211 }
212 };
213
214 // 3. Build the bridge that services llm_query / rlm_query RPCs.
215 let bridge = RlmBridge::new(Arc::clone(&client), child_model.clone(), max_depth);
216 let usage_handle = bridge.usage_handle();
217
218 let _ = tx_event
219 .send(Event::status(format!(
220 "RLM: spawned Python REPL (root={model}, child={child_model}, max_depth={max_depth}, ctx={} chars)",
221 prompt.chars().count()
222 )))
223 .await;
224
225 // 4. Build initial metadata-only history.
226 let system = rlm_system_prompt();
227 let mut messages: Vec<Message> = vec![build_metadata_message(
228 &prompt,
229 root_prompt.as_deref(),
230 0,
231 None,
232 None,
233 )];
234
235 let mut consecutive_no_code: u32 = 0;
236 let mut last_response_text = String::new();
237
238 let result = 'turn: {
239 for iteration in 0..MAX_RLM_ITERATIONS {
240 if start.elapsed() > TURN_TIMEOUT {
241 break 'turn RlmTurnResult {
242 answer: String::new(),
243 iterations: iteration,
244 duration: start.elapsed(),
245 error: Some(format!(
246 "RLM turn timed out after {}s",
247 TURN_TIMEOUT.as_secs()
248 )),
249 usage: total_usage,
250 termination: RlmTermination::Error,
251 trace: trace.clone(),
252 total_rpcs,
253 };
254 }
255
256 let _ = tx_event
257 .send(Event::status(format!(
258 "RLM iteration {}/{}",
259 iteration + 1,
260 MAX_RLM_ITERATIONS
261 )))
262 .await;
263
264 // 4a. Root LLM generates code from metadata-only context.
265 let request = build_root_request(&model, &messages, &system);
266
267 let response = match client.create_message_boxed(request).await {
268 Ok(r) => r,
269 Err(e) => {
270 break 'turn RlmTurnResult {
271 answer: String::new(),
272 iterations: iteration + 1,
273 duration: start.elapsed(),
274 error: Some(format!("Root LLM call failed: {e}")),
275 usage: total_usage,
276 termination: RlmTermination::Error,
277 trace: trace.clone(),
278 total_rpcs,
279 };
280 }
281 };
282
283 total_usage.input_tokens = total_usage
284 .input_tokens
285 .saturating_add(response.usage.input_tokens);
286 total_usage.output_tokens = total_usage
287 .output_tokens
288 .saturating_add(response.usage.output_tokens);
289
290 let response_text = extract_text_blocks(&response.content);
291 last_response_text = response_text.clone();
292
293 // 4b. Top-level FINAL(...) lets the model close out without
294 // touching the REPL — but only if it has done some work
295 // (non-zero rpc_count) on a prior round. Otherwise it's a
296 // shortcut and we reject it.
297 if let Some(final_val) = parse_text_final(&response_text) {
298 if total_rpcs == 0 {
299 // Discard the top-level FINAL — the model is bypassing
300 // the loop. Force it to use the REPL by appending a
301 // strict reminder.
302 consecutive_no_code = consecutive_no_code.saturating_add(1);
303 if consecutive_no_code >= MAX_CONSECUTIVE_NO_CODE {
304 break 'turn RlmTurnResult {
305 answer: final_val,
306 iterations: iteration + 1,
307 duration: start.elapsed(),
308 error: None,
309 usage: total_usage,
310 termination: RlmTermination::NoCode,
311 trace: trace.clone(),
312 total_rpcs,
313 };
314 }
315 messages.push(Message {
316 role: "assistant".to_string(),
317 content: vec![ContentBlock::Text {
318 text: response_text.clone(),
319 cache_control: None,
320 }],
321 });
322 messages.push(Message {
323 role: "user".to_string(),
324 content: vec![ContentBlock::Text {
325 text: "You called FINAL(...) without ever running a ```repl block. \
326 That defeats the recursive language model — you're guessing \
327 from the preview alone. Emit a ```repl block now that uses \
328 `llm_query`, `llm_query_batched`, or `rlm_query` against \
329 `context` to actually compute the answer."
330 .to_string(),
331 cache_control: None,
332 }],
333 });
334 continue;
335 }
336 let _ = tx_event
337 .send(Event::status(
338 "RLM: FINAL detected in response text".to_string(),
339 ))
340 .await;
341 break 'turn RlmTurnResult {
342 answer: final_val,
343 iterations: iteration + 1,
344 duration: start.elapsed(),
345 error: None,
346 usage: total_usage,
347 termination: RlmTermination::Final,
348 trace: trace.clone(),
349 total_rpcs,
350 };
351 }
352
353 // 4c. Extract a ```repl block.
354 let code = extract_repl_code(&response_text);
355 let code_to_run = match code {
356 Some(c) => {
357 consecutive_no_code = 0;
358 c
359 }
360 None => {
361 consecutive_no_code = consecutive_no_code.saturating_add(1);
362 if consecutive_no_code >= MAX_CONSECUTIVE_NO_CODE {
363 break 'turn RlmTurnResult {
364 answer: response_text,
365 iterations: iteration + 1,
366 duration: start.elapsed(),
367 error: Some(format!(
368 "RLM: model failed to emit ```repl after {MAX_CONSECUTIVE_NO_CODE} consecutive rounds"
369 )),
370 usage: total_usage,
371 termination: RlmTermination::NoCode,
372 trace: trace.clone(),
373 total_rpcs,
374 };
375 }
376 messages.push(Message {
377 role: "assistant".to_string(),
378 content: vec![ContentBlock::Text {
379 text: response_text.clone(),
380 cache_control: None,
381 }],
382 });
383 messages.push(Message {
384 role: "user".to_string(),
385 content: vec![ContentBlock::Text {
386 text: "Reminder: emit Python inside a ```repl … ``` fence. \
387 Use `llm_query` / `llm_query_batched` / `rlm_query` to \
388 process `context` and call `FINAL(value)` when done."
389 .to_string(),
390 cache_control: None,
391 }],
392 });
393 continue;
394 }
395 };
396
397 let _ = tx_event
398 .send(Event::MessageDelta {
399 index: iteration as usize,
400 content: format!(
401 "\n[RLM round {} — code]\n```repl\n{code_to_run}\n```\n",
402 iteration + 1
403 ),
404 })
405 .await;
406
407 // 4d. Execute the code in the REPL with the bridge servicing
408 // llm_query / rlm_query callbacks.
409 let round = match repl.run(&code_to_run, Some(&bridge)).await {
410 Ok(r) => r,
411 Err(e) => {
412 break 'turn RlmTurnResult {
413 answer: String::new(),
414 iterations: iteration + 1,
415 duration: start.elapsed(),
416 error: Some(format!("REPL execution failed: {e}")),
417 usage: total_usage,
418 termination: RlmTermination::Error,
419 trace: trace.clone(),
420 total_rpcs,
421 };
422 }
423 };
424
425 total_rpcs = total_rpcs.saturating_add(round.rpc_count);
426
427 // Trace this round.
428 let stdout_preview = truncate_text(round.stdout.trim(), STDOUT_METADATA_PREVIEW_LEN);
429 trace.push(RlmRoundTrace {
430 round: iteration + 1,
431 code_summary: summarize_code(&code_to_run),
432 stdout_preview: stdout_preview.clone(),
433 had_error: round.has_error,
434 rpc_count: round.rpc_count,
435 elapsed_ms: round.elapsed.as_millis() as u64,
436 });
437
438 let _ = tx_event
439 .send(Event::status(format!(
440 "RLM round {}: {} bytes stdout, {} sub-LLM call(s){}",
441 iteration + 1,
442 round.full_stdout.len(),
443 round.rpc_count,
444 if round.has_error { " (error)" } else { "" },
445 )))
446 .await;
447
448 // 4e. FINAL detection.
449 if let Some(final_val) = round.final_value.clone() {
450 let _ = tx_event
451 .send(Event::status(
452 "RLM: FINAL detected in REPL, ending loop".to_string(),
453 ))
454 .await;
455 break 'turn RlmTurnResult {
456 answer: final_val,
457 iterations: iteration + 1,
458 duration: start.elapsed(),
459 error: None,
460 usage: total_usage,
461 termination: RlmTermination::Final,
462 trace: trace.clone(),
463 total_rpcs,
464 };
465 }
466
467 // 4f. Build metadata for next iteration.
468 messages.push(Message {
469 role: "assistant".to_string(),
470 content: vec![ContentBlock::Text {
471 text: format!("```repl\n{code_to_run}\n```"),
472 cache_control: None,
473 }],
474 });
475 messages.push(build_metadata_message(
476 &prompt,
477 root_prompt.as_deref(),
478 iteration + 1,
479 Some(&code_to_run),
480 Some(&stdout_preview),
481 ));
482
483 if messages.len() > MAX_HISTORY_MESSAGES {
484 let drop_from = messages.len() - MAX_HISTORY_MESSAGES + 1;
485 let mut kept = vec![messages[0].clone()];
486 kept.extend(messages.drain(drop_from..));
487 messages = kept;
488 }
489 }
490
491 let _ = last_response_text;
492 RlmTurnResult {
493 answer: String::new(),
494 iterations: MAX_RLM_ITERATIONS,
495 duration: start.elapsed(),
496 error: Some(format!(
497 "RLM loop exhausted after {MAX_RLM_ITERATIONS} iterations without FINAL"
498 )),
499 usage: total_usage,
500 termination: RlmTermination::Exhausted,
501 trace: trace.clone(),
502 total_rpcs,
503 }
504 };
505
506 // Fold bridge usage (children + nested sub_rlm) into totals.
507 let bridge_usage = usage_handle.lock().await;
508 let mut final_usage = result.usage.clone();
509 final_usage.input_tokens = final_usage
510 .input_tokens
511 .saturating_add(bridge_usage.input_tokens);
512 final_usage.output_tokens = final_usage
513 .output_tokens
514 .saturating_add(bridge_usage.output_tokens);
515 drop(bridge_usage);
516
517 repl.shutdown().await;
518
519 RlmTurnResult {
520 usage: final_usage,
521 ..result
522 }
523 }
524
525 // ---------------------------------------------------------------------------
526 // Helpers
527 // ---------------------------------------------------------------------------
528
529 fn write_context_file(prompt: &str) -> std::io::Result<PathBuf> {
530 let dir = std::env::temp_dir().join("deepseek_rlm_ctx");
531 std::fs::create_dir_all(&dir)?;
532 let path = dir.join(format!(
533 "ctx_{}_{}.txt",
534 std::process::id(),
535 Uuid::new_v4().simple()
536 ));
537 std::fs::write(&path, prompt)?;
538 Ok(path)
539 }
540
541 fn build_root_request(model: &str, messages: &[Message], system: &SystemPrompt) -> MessageRequest {
542 MessageRequest {
543 model: model.to_string(),
544 messages: messages.to_vec(),
545 max_tokens: ROOT_MAX_TOKENS,
546 system: Some(system.clone()),
547 tools: None,
548 tool_choice: None,
549 metadata: None,
550 thinking: None,
551 reasoning_effort: None,
552 stream: Some(false),
553 temperature: Some(ROOT_TEMPERATURE),
554 top_p: Some(0.9_f32),
555 }
556 }
557
558 /// Build `Metadata(state)` from the paper. Surfaces:
559 /// - the small `root_prompt` (if any) — repeated each iteration
560 /// - `context` length + preview
561 /// - the REPL helpers
562 /// - the previous round's code summary + stdout preview
563 fn build_metadata_message(
564 prompt: &str,
565 root_prompt: Option<&str>,
566 iteration: u32,
567 previous_code: Option<&str>,
568 previous_stdout: Option<&str>,
569 ) -> Message {
570 let prompt_len = prompt.chars().count();
571 let prompt_preview = truncate_text(prompt, PROMPT_PREVIEW_LEN);
572
573 let mut parts = Vec::new();
574 parts.push(format!("## REPL state (round {iteration})"));
575 parts.push(String::new());
576 if let Some(rp) = root_prompt
577 && !rp.trim().is_empty()
578 {
579 parts.push("**Original task** (re-shown every round)".to_string());
580 parts.push(format!("> {}", truncate_text(rp.trim(), 600)));
581 parts.push(String::new());
582 }
583 parts.push("**`context`** — the long input lives in the REPL only".to_string());
584 parts.push(format!("- Length: {prompt_len} chars"));
585 parts.push(format!("- Preview: \"{prompt_preview}\""));
586 parts.push(String::new());
587
588 parts.push("**REPL helpers** (use inside ```repl blocks)".to_string());
589 parts.push("- `context` / `ctx` — the full input string".to_string());
590 parts.push("- `len(context)` / `context[a:b]` / `context.splitlines()` — slice it".to_string());
591 parts.push(
592 "- `llm_query(prompt, model=None)` — one-shot child LLM; `model` is ignored and child calls stay pinned to Flash"
593 .to_string(),
594 );
595 parts.push(
596 "- `llm_query_batched([p1, p2, ...])` — concurrent fan-out; `model` is ignored"
597 .to_string(),
598 );
599 parts.push(
600 "- `rlm_query(prompt, model=None)` — recursive sub-RLM; `model` is ignored"
601 .to_string(),
602 );
603 parts.push(
604 "- `rlm_query_batched([p1, p2, ...])` — concurrent recursive sub-RLMs; `model` is ignored"
605 .to_string(),
606 );
607 parts.push("- `SHOW_VARS()` — list user variables".to_string());
608 parts.push("- `repl_set(name, value)` / `repl_get(name)` — explicit store".to_string());
609 parts.push(
610 "- `FINAL(value)` — end the loop with this answer".to_string(),
611 );
612 parts.push(
613 "- `FINAL_VAR(name)` — end the loop with a variable's value"
614 .to_string(),
615 );
616 parts.push(String::new());
617
618 if iteration > 0 {
619 parts.push("**Previous round**".to_string());
620 if let Some(code) = previous_code {
621 parts.push(format!("- Code: {}", summarize_code(code)));
622 }
623 if let Some(stdout) = previous_stdout {
624 let stdout_clean = stdout.trim();
625 if !stdout_clean.is_empty() {
626 parts.push(format!("- Stdout preview: \"{stdout_clean}\""));
627 } else {
628 parts.push("- Stdout: (empty)".to_string());
629 }
630 }
631 }
632
633 let text = parts.join("\n");
634
635 Message {
636 role: "user".to_string(),
637 content: vec![ContentBlock::Text {
638 text,
639 cache_control: None,
640 }],
641 }
642 }
643
644 fn summarize_code(code: &str) -> String {
645 let lines: Vec<&str> = code.lines().collect();
646 if lines.len() <= 8 {
647 return code.to_string();
648 }
649 let head = lines[..4].join("\n");
650 let tail = lines[lines.len() - 4..].join("\n");
651 format!("{} lines:\n{head}\n…\n{tail}", lines.len())
652 }
653
654 fn extract_text_blocks(blocks: &[ContentBlock]) -> String {
655 blocks
656 .iter()
657 .filter_map(|b| match b {
658 ContentBlock::Text { text, .. } => Some(text.as_str()),
659 _ => None,
660 })
661 .collect::<Vec<_>>()
662 .join("\n")
663 }
664
665 /// Extract the first ` ```repl ` block from `text`. Falls back to
666 /// ` ```python `/`` ```py `` for compatibility with prompts that learned
667 /// the older fence style.
668 fn extract_repl_code(text: &str) -> Option<String> {
669 let start_markers = [
670 "```repl\n",
671 "```repl\r\n",
672 "```python\n",
673 "```py\n",
674 "```python\r\n",
675 "```py\r\n",
676 ];
677 let mut best_start: Option<(usize, &str)> = None;
678
679 for marker in &start_markers {
680 if let Some(idx) = text.find(marker) {
681 let end_pos = idx + marker.len();
682 match best_start {
683 Some((best_idx, _)) if idx < best_idx => {
684 best_start = Some((idx, &text[end_pos..]));
685 }
686 None => {
687 best_start = Some((idx, &text[end_pos..]));
688 }
689 _ => {}
690 }
691 }
692 }
693
694 let after_fence = best_start.map(|(_, rest)| rest)?;
695
696 let end_idx = after_fence
697 .find("\n```")
698 .or_else(|| after_fence.find("```"))?;
699
700 let code = after_fence[..end_idx].trim().to_string();
701 if code.is_empty() {
702 return None;
703 }
704 Some(code)
705 }
706
707 /// Parse a top-level `FINAL(...)` directive from the model's raw text.
708 /// Mirrors the reference RLM's `find_final_answer`: directive must appear
709 /// at the start of a line, *outside* any code fence.
710 fn parse_text_final(text: &str) -> Option<String> {
711 let outside_fence = strip_code_fences(text);
712
713 for line in outside_fence.lines() {
714 let trimmed = line.trim_start();
715 if trimmed.starts_with("FINAL_VAR(") {
716 // FINAL_VAR can't be resolved from text alone — defer to REPL.
717 continue;
718 }
719 if let Some(rest) = trimmed.strip_prefix("FINAL(") {
720 let inner = rest.trim_end();
721 if let Some(end) = inner.rfind(')') {
722 let value = inner[..end].trim();
723 if !value.is_empty() {
724 return Some(strip_quotes(value));
725 }
726 }
727 }
728 }
729 None
730 }
731
732 fn strip_code_fences(text: &str) -> String {
733 let mut out = String::with_capacity(text.len());
734 let mut in_fence = false;
735 for line in text.lines() {
736 if line.trim_start().starts_with("```") {
737 in_fence = !in_fence;
738 continue;
739 }
740 if !in_fence {
741 out.push_str(line);
742 out.push('\n');
743 }
744 }
745 out
746 }
747
748 fn strip_quotes(s: &str) -> String {
749 let bytes = s.as_bytes();
750 if bytes.len() >= 2
751 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
752 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
753 {
754 return s[1..s.len() - 1].to_string();
755 }
756 s.to_string()
757 }
758
759 fn truncate_text(text: &str, max_chars: usize) -> String {
760 let count = text.chars().count();
761 if count <= max_chars {
762 return text.to_string();
763 }
764 let take = max_chars.saturating_sub(3);
765 let mut result: String = text.chars().take(take).collect();
766 result.push_str("...");
767 result
768 }
769
770 // ---------------------------------------------------------------------------
771 // Tests
772 // ---------------------------------------------------------------------------
773
774 #[cfg(test)]
775 mod tests {
776 use super::*;
777
778 #[test]
779 fn extract_repl_code_finds_simple_block() {
780 let text = "Here:\n```repl\nprint('hi')\n```\nEnd.";
781 let code = extract_repl_code(text).unwrap();
782 assert_eq!(code, "print('hi')");
783 }
784
785 #[test]
786 fn extract_repl_code_falls_back_to_python_marker() {
787 let text = "Code:\n```python\nx = 1 + 2\n```";
788 let code = extract_repl_code(text).unwrap();
789 assert_eq!(code, "x = 1 + 2");
790 }
791
792 #[test]
793 fn extract_repl_code_returns_none_when_missing() {
794 assert!(extract_repl_code("Just text.").is_none());
795 }
796
797 #[test]
798 fn extract_repl_code_returns_none_on_empty_block() {
799 assert!(extract_repl_code("```repl\n\n```").is_none());
800 }
801
802 #[test]
803 fn extract_repl_code_handles_multiple_blocks() {
804 let text = "```repl\na=1\n```\n```repl\nb=2\n```";
805 let code = extract_repl_code(text).unwrap();
806 assert_eq!(code, "a=1");
807 }
808
809 #[test]
810 fn extract_repl_code_ignores_other_fences() {
811 let text = "```\nfoo\n```\n```repl\nreal_code()\n```";
812 let code = extract_repl_code(text).unwrap();
813 assert_eq!(code, "real_code()");
814 }
815
816 #[test]
817 fn parse_text_final_extracts_simple_value() {
818 let text = "OK.\nFINAL(42)\nThanks.";
819 assert_eq!(parse_text_final(text).as_deref(), Some("42"));
820 }
821
822 #[test]
823 fn parse_text_final_strips_quotes() {
824 let text = "FINAL(\"the answer is yes\")";
825 assert_eq!(parse_text_final(text).as_deref(), Some("the answer is yes"));
826 }
827
828 #[test]
829 fn parse_text_final_ignores_inside_code_fence() {
830 let text =
831 "Some prose.\n```repl\n# Note: when ready, call FINAL(value)\nx = 1\n```\nMore prose.";
832 assert!(parse_text_final(text).is_none());
833 }
834
835 #[test]
836 fn parse_text_final_returns_none_when_absent() {
837 assert!(parse_text_final("just talking, no final.").is_none());
838 }
839
840 #[test]
841 fn build_metadata_contains_key_information() {
842 let msg = build_metadata_message("Hello, world!", None, 0, None, None);
843 let text = extract_text_blocks(&msg.content);
844 assert!(text.contains("context"));
845 assert!(text.contains("Hello, world!"));
846 assert!(text.contains("round 0"));
847 assert!(text.contains("llm_query"));
848 assert!(text.contains("rlm_query"));
849 assert!(text.contains("FINAL"));
850 }
851
852 #[test]
853 fn build_metadata_truncates_long_context_without_leaking_tail() {
854 let secret_tail = "DO_NOT_LEAK_CONTEXT_TAIL";
855 let prompt = format!("{}{}", "a".repeat(PROMPT_PREVIEW_LEN + 100), secret_tail);
856 let msg = build_metadata_message(&prompt, None, 0, None, None);
857 let text = extract_text_blocks(&msg.content);
858
859 assert!(text.contains(&format!("- Length: {} chars", prompt.chars().count())));
860 assert!(text.contains("- Preview: \""));
861 assert!(text.contains("..."));
862 assert!(
863 !text.contains(secret_tail),
864 "metadata leaked the non-preview tail of context"
865 );
866 }
867
868 #[test]
869 fn build_root_request_keeps_context_tail_out_of_root_payload() {
870 let secret_tail = "DO_NOT_LEAK_ROOT_REQUEST";
871 let prompt = format!("{}{}", "a".repeat(PROMPT_PREVIEW_LEN + 100), secret_tail);
872 let messages = vec![build_metadata_message(
873 &prompt,
874 Some("answer from the long context"),
875 0,
876 None,
877 None,
878 )];
879
880 let request = build_root_request("root-model", &messages, &rlm_system_prompt());
881 let payload = serde_json::to_string(&request).expect("request should serialize");
882
883 assert!(payload.contains(&format!("- Length: {} chars", prompt.chars().count())));
884 assert!(
885 !payload.contains(secret_tail),
886 "root LLM request leaked the non-preview tail of context"
887 );
888 }
889
890 #[test]
891 fn build_metadata_with_iteration_shows_previous_code() {
892 let msg = build_metadata_message("Test prompt", None, 3, Some("print('hi')"), Some("hi"));
893 let text = extract_text_blocks(&msg.content);
894 assert!(text.contains("round 3"));
895 assert!(text.contains("print('hi')"));
896 assert!(text.contains("hi"));
897 }
898
899 #[test]
900 fn build_metadata_includes_root_prompt() {
901 let msg = build_metadata_message(
902 "long context",
903 Some("Summarize the security model"),
904 1,
905 Some("# noop"),
906 Some("ok"),
907 );
908 let text = extract_text_blocks(&msg.content);
909 assert!(text.contains("Original task"));
910 assert!(text.contains("Summarize the security model"));
911 }
912
913 #[test]
914 fn truncate_text_leaves_short_alone() {
915 assert_eq!(truncate_text("hello", 100), "hello");
916 }
917
918 #[test]
919 fn truncate_text_shortens_long_text() {
920 let long = "a".repeat(1000);
921 let truncated = truncate_text(&long, 10);
922 assert_eq!(truncated.chars().count(), 10);
923 assert!(truncated.ends_with("..."));
924 }
925
926 #[test]
927 fn truncate_text_is_unicode_safe() {
928 let s = "日本語テスト";
929 let out = truncate_text(s, 4);
930 assert_eq!(out.chars().count(), 4);
931 assert!(out.ends_with("..."));
932 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
933 }
934
935 #[test]
936 fn extract_text_blocks_joins_text() {
937 let blocks = vec![
938 ContentBlock::Text {
939 text: "first".to_string(),
940 cache_control: None,
941 },
942 ContentBlock::Thinking {
943 thinking: "skip".to_string(),
944 },
945 ContentBlock::Text {
946 text: "second".to_string(),
947 cache_control: None,
948 },
949 ];
950 assert_eq!(extract_text_blocks(&blocks), "first\nsecond");
951 }
952
953 #[test]
954 fn metadata_msg_role_is_user() {
955 let msg = build_metadata_message("test", None, 0, None, None);
956 assert_eq!(msg.role, "user");
957 }
958
959 #[test]
960 fn summarize_code_keeps_short() {
961 assert_eq!(summarize_code("a\nb\nc"), "a\nb\nc");
962 }
963
964 #[test]
965 fn summarize_code_compresses_long() {
966 let lines: Vec<String> = (0..20).map(|i| format!("line{i}")).collect();
967 let code = lines.join("\n");
968 let s = summarize_code(&code);
969 assert!(s.starts_with("20 lines:"));
970 assert!(s.contains("line0"));
971 assert!(s.contains("line19"));
972 assert!(s.contains("…"));
973 }
974 }
975
975 lines RUST