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