返回 CodeWhale
rlm.rs
根目录 / crates / tui / src / tools / rlm.rs
1 //! Compatibility persistent-RLM session tools.
2 //!
3 //! v0.8.33 replaces the old one-shot `rlm` tool with a head/hands surface:
4 //! `rlm_open` creates a named Python kernel over a large context,
5 //! `rlm_eval` runs bounded probes against it, `rlm_configure` adjusts runtime
6 //! feedback, and `rlm_close` tears it down.
7 //!
8 //! The normal Agent path now owns one session-persistent `repl` kernel. This
9 //! action-shaped surface stays registered for explicit compatibility and saved
10 //! transcript replay, but is hidden from new model turns. Its `rlm_*` aliases
11 //! force the action so old transcripts replay correctly — the pattern
12 //! `BashTool` established for `exec_shell*` in #4625.
13
14 use std::sync::Arc;
15 use std::time::{Duration, Instant};
16
17 use async_trait::async_trait;
18 use serde_json::{Value, json};
19
20 use crate::client::DeepSeekClient;
21 use crate::repl::PythonRuntime;
22 use crate::rlm::RlmBridge;
23 use crate::rlm::session::{
24 ContextMeta, OutputFeedback, RlmSession, derive_session_name, write_context_file,
25 };
26 use crate::tools::fetch_url::FetchUrlTool;
27 use crate::tools::handle::VarHandle;
28 use crate::tools::spec::{
29 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
30 };
31
32 const DEFAULT_CHILD_MODEL: &str = "deepseek-v4-flash";
33 const MAX_INLINE_CONTENT_CHARS: usize = 200_000;
34 const FULL_STDOUT_HEAD_CHARS: usize = 4_096;
35 const FULL_STDOUT_TAIL_CHARS: usize = 1_024;
36
37 /// When `rlm_eval` stdout exceeds this many characters the full body is
38 /// stored as a `var_handle` instead of inlined into the parent transcript.
39 /// The model retrieves the body via `handle_read` using the returned handle.
40 const STDOUT_HANDLE_THRESHOLD_CHARS: usize = 1_000;
41 const HARD_SUB_RLM_DEPTH_CAP: u32 = 3;
42
43 const ALL_ACTIONS: &[&str] = &["session_objects", "open", "eval", "configure", "close"];
44
45 fn rlm_kernel_error_result(
46 error: &str,
47 elapsed: Duration,
48 route: &crate::cost_status::EffectiveRouteEnvelope,
49 usage: &crate::models::Usage,
50 ) -> ToolResult {
51 let mut metadata = json!({
52 // The registered tool is `rlm`; `eval` is its action. Naming a
53 // retired `rlm_eval` tool here taught the model a call it cannot
54 // make (2026-08-04 audit).
55 "tool": "rlm",
56 "action": "eval",
57 "duration_ms": elapsed.as_millis() as u64,
58 "kernel_error": true,
59 });
60 crate::cost_status::attach_child_usage_metadata(&mut metadata, route, usage);
61 ToolResult::error(format!("rlm action='eval': {error}")).with_metadata(metadata)
62 }
63
64 /// Unified RLM session tool.
65 ///
66 /// One struct and one input schema for the canonical `rlm` tool. `client` is
67 /// only exercised by the `eval` action (child sub-RLM queries); other actions
68 /// ignore it.
69 pub struct RlmTool {
70 name: &'static str,
71 forced_action: Option<&'static str>,
72 client: Option<DeepSeekClient>,
73 /// Kept only for replay-compatible explicit RLM sessions. New normal
74 /// agent work uses the session kernel and inherits its route there.
75 root_model: String,
76 }
77
78 impl RlmTool {
79 #[must_use]
80 pub fn new(name: &'static str, client: Option<DeepSeekClient>) -> Self {
81 Self {
82 name,
83 forced_action: None,
84 client,
85 root_model: DEFAULT_CHILD_MODEL.to_string(),
86 }
87 }
88
89 /// Bind an explicit compatibility session to the active parent route.
90 /// This prevents a saved/manual RLM invocation from silently falling back
91 /// to an unrelated legacy child model.
92 #[must_use]
93 pub fn with_root_model(mut self, root_model: String) -> Self {
94 self.root_model = root_model;
95 self
96 }
97
98 #[cfg(test)]
99 #[must_use]
100 pub fn alias(name: &'static str, action: &'static str, client: Option<DeepSeekClient>) -> Self {
101 Self {
102 name,
103 forced_action: Some(action),
104 client,
105 root_model: DEFAULT_CHILD_MODEL.to_string(),
106 }
107 }
108
109 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
110 let action = match self.forced_action {
111 Some(action) => action,
112 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
113 ToolError::invalid_input(format!(
114 "rlm: missing `action` (one of: {})",
115 ALL_ACTIONS.join(", ")
116 ))
117 })?,
118 };
119 if ALL_ACTIONS.contains(&action) {
120 Ok(action)
121 } else {
122 Err(ToolError::invalid_input(format!(
123 "rlm: invalid action `{action}` (one of: {})",
124 ALL_ACTIONS.join(", ")
125 )))
126 }
127 }
128
129 /// Mirror of the legacy per-tool approval contract: only `rlm_eval`
130 /// required approval (it is the non-bypassable code-eval surface, #3866).
131 fn action_requires_approval(action: &str) -> bool {
132 action == "eval"
133 }
134
135 /// Mirror of the legacy per-tool read-only contract (capability-derived):
136 /// `rlm_open` carries `ExecutesCode`, so only session_objects / configure /
137 /// close counted as read-only.
138 fn action_is_read_only(action: &str) -> bool {
139 matches!(action, "session_objects" | "configure" | "close")
140 }
141
142 fn action_capabilities(action: &str) -> Vec<ToolCapability> {
143 match action {
144 "session_objects" => vec![ToolCapability::ReadOnly],
145 "open" => vec![
146 ToolCapability::ReadOnly,
147 ToolCapability::Network,
148 ToolCapability::ExecutesCode,
149 ],
150 "eval" => vec![
151 ToolCapability::Network,
152 ToolCapability::ExecutesCode,
153 ToolCapability::RequiresApproval,
154 ],
155 // configure / close
156 _ => vec![ToolCapability::ReadOnly],
157 }
158 }
159 }
160
161 #[async_trait]
162 impl ToolSpec for RlmTool {
163 fn name(&self) -> &'static str {
164 self.name
165 }
166
167 fn model_visible(&self) -> bool {
168 // The normal Agent path owns a session-scoped `repl` kernel. Keep the
169 // old action fan-out registered for replay and explicit compatibility,
170 // but do not teach a second RLM workflow to new model turns.
171 false
172 }
173
174 fn description(&self) -> &'static str {
175 match self.forced_action {
176 Some("session_objects") => {
177 "List active prompt/history/session symbolic objects as compact cards. \
178 Pass one of the returned `id` values to `rlm_open` as \
179 `session_object` to inspect it inside an RLM REPL without copying the \
180 full prompt or transcript into the parent context."
181 }
182 Some("open") => {
183 "Open a persistent RLM context. Loads `file_path`, `content`, `url`, \
184 or `session_object` into a named Python kernel and returns only \
185 metadata: name, length, preview, and sha256. Use this for large or \
186 unfamiliar inputs so the parent transcript holds a handle, not the \
187 body."
188 }
189 Some("eval") => {
190 "Run one Python REPL block against a named RLM context. Returns a \
191 bounded projection of stdout/stderr plus metadata. If the code calls \
192 FINAL/finalize, the final value is stored as a var_handle retrievable \
193 with handle_read instead of copied unbounded into the parent context. \
194 Large stdout/stderr payloads (>1k chars) are also stored as \
195 var_handles (returned in stdout_handle / stderr_handle) to keep the \
196 parent transcript lean. Batch child helpers require \
197 dependency_mode='independent'; use sub_query_sequence or a \
198 sequential loop for dependent work."
199 }
200 Some("configure") => {
201 "Configure a named RLM context: output feedback, child query timeout, \
202 recursive sub-RLM depth, and explicit session sharing."
203 }
204 Some("close") => {
205 "Close a named RLM context, tear down its Python kernel, and return \
206 usage/lifecycle metadata."
207 }
208 _ => {
209 "Persistent RLM sessions over large contexts. Actions: \"session_objects\" \
210 (list active prompt/history/session symbolic objects as compact cards), \
211 \"open\" (load file_path/content/url/session_object into a named Python \
212 kernel; returns only metadata so the parent transcript holds a handle, \
213 not the body), \"eval\" (run one bounded Python REPL block against a \
214 named context; approval required; FINAL/finalize values and large \
215 stdout/stderr become var_handles retrievable with handle_read), \
216 \"configure\" (output feedback, child timeout, sub-RLM depth, session \
217 sharing), \"close\" (tear down the kernel and return usage metadata)."
218 }
219 }
220 }
221
222 fn input_schema(&self) -> Value {
223 if let Some(action) = self.forced_action {
224 return legacy_action_schema(action);
225 }
226 json!({
227 "type": "object",
228 "properties": {
229 "action": {
230 "type": "string",
231 "enum": ALL_ACTIONS,
232 "description": "Action to perform."
233 },
234 "name": {
235 "type": "string",
236 "description": "RLM context name, unique within this parent session (action=open: optional, defaults to a slug from the source). Required for action=eval/configure/close."
237 },
238 "file_path": {
239 "type": "string",
240 "description": "Workspace-relative file to load (action=open; exactly one of file_path/content/url/session_object)."
241 },
242 "content": {
243 "type": "string",
244 "description": "Inline content to load. Capped at 200k chars. (action=open)"
245 },
246 "url": {
247 "type": "string",
248 "description": "HTTP/HTTPS URL to fetch (through the same path as Web action=\"fetch\") and load. (action=open)"
249 },
250 "session_object": {
251 "type": "string",
252 "description": "Stable symbolic active-session ref from action=session_objects, for example session://active/system_prompt or session://active/messages/0. (action=open)"
253 },
254 "code": {
255 "type": "string",
256 "description": "Raw Python executed against the context (no markdown fences). The loaded source is in scope as `content`; call FINAL(value)/finalize(...) to return a result handle. Example: print(len(content)). (action=eval)"
257 },
258 "output_feedback": {
259 "type": "string",
260 "enum": ["full", "metadata"],
261 "description": "(action=configure)"
262 },
263 "sub_query_timeout_secs": {
264 "type": "integer",
265 "description": "(action=configure)"
266 },
267 "sub_rlm_max_depth": {
268 "type": "integer",
269 "minimum": 0,
270 "maximum": 3,
271 "description": "(action=configure)"
272 },
273 "share_session": {
274 "type": "boolean",
275 "description": "(action=configure)"
276 }
277 },
278 "additionalProperties": false
279 })
280 }
281
282 fn capabilities(&self) -> Vec<ToolCapability> {
283 match self.forced_action {
284 Some(action) => Self::action_capabilities(action),
285 None => vec![
286 ToolCapability::Network,
287 ToolCapability::ExecutesCode,
288 ToolCapability::RequiresApproval,
289 ],
290 }
291 }
292
293 fn approval_requirement(&self) -> ApprovalRequirement {
294 match self.forced_action {
295 Some(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
296 Some(_) => ApprovalRequirement::Auto,
297 None => ApprovalRequirement::Required,
298 }
299 }
300
301 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
302 match self.resolve_action(input) {
303 Ok(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
304 Ok(_) => ApprovalRequirement::Auto,
305 Err(_) => self.approval_requirement(),
306 }
307 }
308
309 fn is_read_only_for(&self, input: &Value) -> bool {
310 match self.resolve_action(input) {
311 Ok(action) => Self::action_is_read_only(action),
312 Err(_) => self.is_read_only(),
313 }
314 }
315
316 fn supports_parallel(&self) -> bool {
317 matches!(self.forced_action, Some("session_objects"))
318 }
319
320 fn supports_parallel_for(&self, input: &Value) -> bool {
321 matches!(self.resolve_action(input), Ok("session_objects"))
322 }
323
324 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
325 match self.resolve_action(&input)? {
326 "session_objects" => self.execute_session_objects(context).await,
327 "open" => self.execute_open(&input, context).await,
328 "eval" => self.execute_eval(&input, context).await,
329 "configure" => self.execute_configure(&input, context).await,
330 "close" => self.execute_close(&input, context).await,
331 action => Err(ToolError::invalid_input(format!(
332 "rlm: invalid action `{action}`"
333 ))),
334 }
335 }
336 }
337
338 impl RlmTool {
339 async fn execute_session_objects(
340 &self,
341 context: &ToolContext,
342 ) -> Result<ToolResult, ToolError> {
343 let snapshot = context.session_objects.as_ref().ok_or_else(|| {
344 ToolError::not_available("rlm_session_objects: active session snapshot unavailable")
345 })?;
346 ToolResult::json(&json!({
347 "objects": snapshot.object_cards(),
348 "open_with": {
349 "tool": "rlm",
350 "action": "open",
351 "field": "session_object",
352 "example": {
353 "name": "active_prompt",
354 "session_object": "session://active/system_prompt"
355 }
356 },
357 "redaction": "Large tool results and thinking blocks are represented by compact metadata in transcript objects; use returned handles and handle_read for bounded payload projections."
358 }))
359 .map_err(|e| ToolError::execution_failed(e.to_string()))
360 }
361
362 async fn execute_open(
363 &self,
364 input: &Value,
365 context: &ToolContext,
366 ) -> Result<ToolResult, ToolError> {
367 let source_count = rlm_open_source_count(input);
368 if source_count != 1 {
369 let mut msg = String::from(
370 "rlm_open: provide exactly one of `file_path` (local file), `content` (inline text), `url`, or `session_object`",
371 );
372 // "did you mean" for common misnamings (#2655).
373 if let Some(obj) = input.as_object() {
374 let seen: Vec<&str> = [
375 "prompt",
376 "resident_file",
377 "text",
378 "body",
379 "path",
380 "file",
381 "source",
382 ]
383 .into_iter()
384 .filter(|k| obj.contains_key(*k))
385 .collect();
386 if !seen.is_empty() {
387 msg.push_str(&format!(
388 ". Saw {seen:?} — did you mean file_path/content/url/session_object? (to evaluate against an existing context, pass its name to rlm action='eval', or use `session_object`)"
389 ));
390 }
391 }
392 return Err(ToolError::invalid_input(msg));
393 }
394
395 let (body, source_type, source_hint) = load_source(input, context).await?;
396 if body.trim().is_empty() {
397 return Err(ToolError::invalid_input(
398 "rlm_open: input is empty after loading",
399 ));
400 }
401
402 let name = input
403 .get("name")
404 .and_then(Value::as_str)
405 .map(str::trim)
406 .filter(|name| !name.is_empty())
407 .map(ToOwned::to_owned)
408 .unwrap_or_else(|| derive_session_name(source_hint.as_deref()));
409
410 {
411 let sessions = context.runtime.rlm_sessions.lock().await;
412 if sessions.contains_key(&name) {
413 return Err(ToolError::invalid_input(format!(
414 "rlm_open: context name `{name}` already exists"
415 )));
416 }
417 }
418
419 let context_path = write_context_file(&body).map_err(|e| {
420 ToolError::execution_failed(format!("rlm_open: failed to stage context: {e}"))
421 })?;
422 let kernel = PythonRuntime::spawn_with_context(&context_path)
423 .await
424 .map_err(|e| ToolError::execution_failed(format!("rlm_open: {e}")))?;
425 let context_meta = ContextMeta::from_body(&body, source_type);
426 let session = RlmSession::new(name.clone(), kernel, context_meta.clone(), context_path);
427 let id = session.id.clone();
428
429 let mut sessions = context.runtime.rlm_sessions.lock().await;
430 sessions.insert(name.clone(), Arc::new(tokio::sync::Mutex::new(session)));
431
432 ToolResult::json(&json!({
433 "name": name,
434 "id": id,
435 "length": context_meta.length,
436 "type": context_meta.type_name,
437 "preview_500": context_meta.preview_500,
438 "sha256": context_meta.sha256,
439 }))
440 .map_err(|e| ToolError::execution_failed(e.to_string()))
441 }
442
443 async fn execute_eval(
444 &self,
445 input: &Value,
446 context: &ToolContext,
447 ) -> Result<ToolResult, ToolError> {
448 let name = required_non_empty_str(input, "name")?;
449 let code = required_non_empty_str(input, "code").map_err(|_| {
450 ToolError::invalid_input(
451 "rlm_eval: `code` is required and runs raw Python against the RLM context (no markdown fences). \
452 Example: {\"name\": \"<ctx>\", \"code\": \"print(len(content))\"}; call FINAL(value) to return a result handle.",
453 )
454 })?;
455 let session = get_session(context, name).await?;
456 let mut session = session.lock().await;
457 let config = session.config.clone();
458
459 let Some(kernel) = session.kernel.as_mut() else {
460 return Err(ToolError::invalid_input(format!(
461 "rlm_eval: context `{name}` is closed"
462 )));
463 };
464
465 let started = Instant::now();
466 let (round, child_usage, child_route) = if let Some(client) = self.client.clone() {
467 let route = client.effective_route_envelope(&self.root_model, chrono::Utc::now());
468 let bridge = RlmBridge::new(
469 Arc::new(client),
470 self.root_model.clone(),
471 config.sub_rlm_max_depth.min(HARD_SUB_RLM_DEPTH_CAP),
472 );
473 let usage_handle = bridge.usage_handle();
474 let round_result = kernel.run(code, Some(&bridge)).await;
475 let usage = usage_handle.lock().await.clone();
476 let round = match round_result {
477 Ok(round) => round,
478 Err(error) => {
479 // A bridge request may have completed and accrued usage
480 // before the Python kernel times out or closes stdout.
481 // Return a failed ToolResult (rather than a bare ToolError)
482 // so ToolCallComplete still carries the immutable child
483 // receipt and the runtime can durably account for it.
484 session.last_used_at = Instant::now();
485 return Ok(rlm_kernel_error_result(
486 &error.to_string(),
487 started.elapsed(),
488 &route,
489 &usage,
490 ));
491 }
492 };
493 (round, usage, Some(route))
494 } else {
495 let round = kernel
496 .run(code, None::<&RlmBridge>)
497 .await
498 .map_err(|e| ToolError::execution_failed(format!("rlm_eval: {e}")))?;
499 (round, Default::default(), None)
500 };
501
502 session.rpc_count = session.rpc_count.saturating_add(round.rpc_count);
503 session.total_duration += round.elapsed;
504 session.last_used_at = Instant::now();
505
506 let final_handle = if let Some(value_json) = round.final_json.clone() {
507 session.final_count = session.final_count.saturating_add(1);
508 let handle_name = format!("final_{}", session.final_count);
509 let handle = {
510 let mut store = context.runtime.handle_store.lock().await;
511 match value_json {
512 Value::String(value) => {
513 store.insert_text(session.id.clone(), handle_name, value)
514 }
515 other => store.insert_json(session.id.clone(), handle_name, other),
516 }
517 };
518 Some(handle)
519 } else {
520 None
521 };
522
523 let had_error = round.has_error;
524 let rpc_count = round.rpc_count;
525 let duration_ms = round.elapsed.as_millis() as u64;
526 // Route large stdout/stderr into a var_handle to avoid bloat in
527 // the parent transcript. The model calls handle_read for bounded
528 // projections; a short inline note describes availability.
529 fn route_output(
530 text: &str,
531 feedback: &OutputFeedback,
532 store: &mut crate::tools::handle::HandleStore,
533 session_id: &str,
534 tag: &str,
535 ) -> (Option<String>, Option<crate::tools::handle::VarHandle>) {
536 let threshold = STDOUT_HANDLE_THRESHOLD_CHARS;
537 match (feedback, text.len()) {
538 (OutputFeedback::Full, len) if len <= threshold => {
539 (Some(preview_output(text)), None)
540 }
541 (OutputFeedback::Full, _) if !text.trim().is_empty() => {
542 // Store full body as a handle for out-of-band retrieval
543 let name = format!("{tag}_{}", 0); // single counter is fine
544 let handle = store.insert_text(session_id, name, text);
545 (
546 Some(format!("{} chars; retrieve via handle_read", text.len())),
547 Some(handle),
548 )
549 }
550 _ => (None, None),
551 }
552 }
553
554 let (stdout_preview, stdout_handle) = route_output(
555 &round.full_stdout,
556 &config.output_feedback,
557 &mut *context.runtime.handle_store.lock().await,
558 &session.id,
559 "stdout",
560 );
561 let (stderr_preview, stderr_handle) = route_output(
562 &round.stderr,
563 &config.output_feedback,
564 &mut *context.runtime.handle_store.lock().await,
565 &session.id,
566 "stderr",
567 );
568
569 let mut output = json!({
570 "name": session.name,
571 "id": session.id,
572 "duration_ms": duration_ms,
573 "rpc_count": rpc_count,
574 "had_error": had_error,
575 "new_vars": [],
576 "final": final_handle,
577 });
578 if let Some(ref stdout_preview) = stdout_preview {
579 output["stdout_preview"] = json!(stdout_preview);
580 }
581 if let Some(ref stderr_preview) = stderr_preview {
582 output["stderr_preview"] = json!(stderr_preview);
583 }
584 if let (Some(h), Some(_)) = (stdout_handle, &stdout_preview) {
585 output["stdout_handle"] = json!(h);
586 }
587 if let (Some(h), Some(_)) = (stderr_handle, &stderr_preview) {
588 output["stderr_handle"] = json!(h);
589 }
590 if let Some(confidence) = round.final_confidence.clone() {
591 output["confidence"] = confidence;
592 }
593
594 let mut metadata = json!({
595 "tool": "rlm_eval",
596 "duration_ms": started.elapsed().as_millis() as u64,
597 });
598 // RLM fans out dozens of child rounds, so an undercounted class here
599 // scales; report every billable class from the shared producer (#4318).
600 if let Some(route) = child_route.as_ref() {
601 crate::cost_status::attach_child_usage_metadata(&mut metadata, route, &child_usage);
602 }
603
604 Ok(ToolResult::json(&output)
605 .map_err(|e| ToolError::execution_failed(e.to_string()))?
606 .with_metadata(metadata))
607 }
608
609 async fn execute_configure(
610 &self,
611 input: &Value,
612 context: &ToolContext,
613 ) -> Result<ToolResult, ToolError> {
614 let name = required_non_empty_str(input, "name")?;
615 let session = get_session(context, name).await?;
616 let mut session = session.lock().await;
617
618 if let Some(value) = input.get("output_feedback").and_then(Value::as_str) {
619 session.config.output_feedback = match value {
620 "full" => OutputFeedback::Full,
621 "metadata" => OutputFeedback::Metadata,
622 other => {
623 return Err(ToolError::invalid_input(format!(
624 "rlm_configure: invalid output_feedback `{other}`"
625 )));
626 }
627 };
628 }
629 if let Some(timeout) = input.get("sub_query_timeout_secs").and_then(Value::as_u64) {
630 session.config.sub_query_timeout_secs = timeout.clamp(1, 600);
631 }
632 if let Some(depth) = input.get("sub_rlm_max_depth").and_then(Value::as_u64) {
633 session.config.sub_rlm_max_depth = (depth as u32).min(HARD_SUB_RLM_DEPTH_CAP);
634 }
635 if let Some(share) = input.get("share_session").and_then(Value::as_bool) {
636 session.config.share_session = share;
637 }
638
639 ToolResult::json(&json!({
640 "name": session.name,
641 "current_config": session.config,
642 }))
643 .map_err(|e| ToolError::execution_failed(e.to_string()))
644 }
645
646 async fn execute_close(
647 &self,
648 input: &Value,
649 context: &ToolContext,
650 ) -> Result<ToolResult, ToolError> {
651 let name = required_non_empty_str(input, "name")?;
652 let removed = {
653 let mut sessions = context.runtime.rlm_sessions.lock().await;
654 sessions.remove(name)
655 };
656 let Some(session) = removed else {
657 return Err(ToolError::invalid_input(format!(
658 "rlm_close: unknown context `{name}`"
659 )));
660 };
661
662 let mut session = session.lock().await;
663 let kernel = session.kernel.take();
664 let output = json!({
665 "name": session.name,
666 "id": session.id,
667 "rpc_count": session.rpc_count,
668 "total_duration_ms": session.total_duration.as_millis() as u64,
669 "peak_var_count": session.peak_var_count,
670 "created_ms_ago": session.created_at.elapsed().as_millis() as u64,
671 "context_path": session.context_path,
672 });
673 drop(session);
674
675 if let Some(kernel) = kernel {
676 kernel.shutdown().await;
677 }
678
679 ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string()))
680 }
681 }
682
683 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
684 /// registrations report an identical contract to the pre-unification tools.
685 fn legacy_action_schema(action: &str) -> Value {
686 match action {
687 "session_objects" => json!({
688 "type": "object",
689 "properties": {}
690 }),
691 "open" => json!({
692 "type": "object",
693 "properties": {
694 "name": {
695 "type": "string",
696 "description": "Caller-chosen context name, unique within this parent session. Defaults to a slug from the source."
697 },
698 "file_path": {
699 "type": "string",
700 "description": "Workspace-relative file to load."
701 },
702 "content": {
703 "type": "string",
704 "description": "Inline content to load. Capped at 200k chars."
705 },
706 "url": {
707 "type": "string",
708 "description": "HTTP/HTTPS URL to fetch (through the same path as Web action=\"fetch\") and load."
709 },
710 "session_object": {
711 "type": "string",
712 "description": "Stable symbolic active-session ref from rlm_session_objects, for example session://active/system_prompt or session://active/messages/0."
713 }
714 }
715 }),
716 "eval" => json!({
717 "type": "object",
718 "required": ["name", "code"],
719 "properties": {
720 "name": { "type": "string", "description": "RLM context name returned by rlm_open." },
721 "code": { "type": "string", "description": "Raw Python executed against the context (no markdown fences). The loaded source is in scope as `content`; call FINAL(value)/finalize(...) to return a result handle. Example: print(len(content))." }
722 }
723 }),
724 "configure" => json!({
725 "type": "object",
726 "required": ["name"],
727 "properties": {
728 "name": { "type": "string" },
729 "output_feedback": { "type": "string", "enum": ["full", "metadata"] },
730 "sub_query_timeout_secs": { "type": "integer" },
731 "sub_rlm_max_depth": { "type": "integer", "minimum": 0, "maximum": 3 },
732 "share_session": { "type": "boolean" }
733 }
734 }),
735 // close
736 _ => json!({
737 "type": "object",
738 "required": ["name"],
739 "properties": {
740 "name": { "type": "string", "description": "RLM context name from rlm_open." }
741 }
742 }),
743 }
744 }
745
746 async fn load_source(
747 input: &Value,
748 context: &ToolContext,
749 ) -> Result<(String, String, Option<String>), ToolError> {
750 if let Some(path) = rlm_open_source_field(input, "file_path").map(str::trim) {
751 let resolved = context.resolve_path(path)?;
752 let body = tokio::fs::read_to_string(&resolved).await.map_err(|e| {
753 ToolError::execution_failed(format!("rlm_open: read {}: {e}", resolved.display()))
754 })?;
755 return Ok((body, "file".to_string(), Some(path.to_string())));
756 }
757
758 if let Some(content) = rlm_open_source_field(input, "content") {
759 if content.chars().count() > MAX_INLINE_CONTENT_CHARS {
760 return Err(ToolError::invalid_input(format!(
761 "rlm_open: inline content is {} chars (cap {MAX_INLINE_CONTENT_CHARS})",
762 content.chars().count()
763 )));
764 }
765 return Ok((content.to_string(), "content".to_string(), None));
766 }
767
768 if let Some(object_ref) = rlm_open_source_field(input, "session_object") {
769 let snapshot = context.session_objects.as_ref().ok_or_else(|| {
770 ToolError::not_available("rlm_open: active session snapshot unavailable")
771 })?;
772 let object = snapshot.resolve(object_ref).ok_or_else(|| {
773 ToolError::invalid_input(format!("rlm_open: unknown session object `{object_ref}`"))
774 })?;
775 return Ok((
776 object.body,
777 format!("session_object:{}", object.kind),
778 Some(object.id),
779 ));
780 }
781
782 let url = rlm_open_source_field(input, "url")
783 .map(str::trim)
784 .ok_or_else(|| ToolError::invalid_input("rlm_open: missing source"))?;
785 let result = FetchUrlTool
786 .execute(json!({"url": url, "format": "raw"}), context)
787 .await?;
788 let parsed: Value = serde_json::from_str(&result.content).map_err(|e| {
789 ToolError::execution_failed(format!("rlm_open: fetch_url returned invalid JSON: {e}"))
790 })?;
791 let body = parsed
792 .get("content")
793 .and_then(Value::as_str)
794 .ok_or_else(|| ToolError::execution_failed("rlm_open: fetched body missing content"))?
795 .to_string();
796 let source_type = parsed
797 .get("content_type")
798 .and_then(Value::as_str)
799 .unwrap_or("url")
800 .to_string();
801 Ok((body, source_type, Some(url.to_string())))
802 }
803
804 fn rlm_open_source_count(input: &Value) -> usize {
805 ["file_path", "content", "url", "session_object"]
806 .iter()
807 .filter(|field| rlm_open_source_field(input, field).is_some())
808 .count()
809 }
810
811 fn rlm_open_source_field<'a>(input: &'a Value, field: &str) -> Option<&'a str> {
812 input
813 .get(field)
814 .and_then(Value::as_str)
815 .filter(|value| !value.trim().is_empty())
816 }
817
818 async fn get_session(
819 context: &ToolContext,
820 name: &str,
821 ) -> Result<Arc<tokio::sync::Mutex<RlmSession>>, ToolError> {
822 let sessions = context.runtime.rlm_sessions.lock().await;
823 sessions.get(name).cloned().ok_or_else(|| {
824 ToolError::invalid_input(format!(
825 "unknown RLM context `{name}`; open it first with rlm action='open'"
826 ))
827 })
828 }
829
830 fn required_non_empty_str<'a>(input: &'a Value, field: &str) -> Result<&'a str, ToolError> {
831 let value = input
832 .get(field)
833 .and_then(Value::as_str)
834 .ok_or_else(|| ToolError::missing_field(field))?
835 .trim();
836 if value.is_empty() {
837 return Err(ToolError::invalid_input(format!(
838 "rlm: `{field}` must not be empty"
839 )));
840 }
841 Ok(value)
842 }
843
844 fn preview_output(text: &str) -> String {
845 let total = text.chars().count();
846 if total <= FULL_STDOUT_HEAD_CHARS + FULL_STDOUT_TAIL_CHARS {
847 return text.to_string();
848 }
849 let head: String = text.chars().take(FULL_STDOUT_HEAD_CHARS).collect();
850 let tail: String = text
851 .chars()
852 .skip(total.saturating_sub(FULL_STDOUT_TAIL_CHARS))
853 .collect();
854 format!(
855 "{head}\n... [{} chars truncated, retrieve via handle_read when returned as a handle] ...\n{tail}",
856 total.saturating_sub(FULL_STDOUT_HEAD_CHARS + FULL_STDOUT_TAIL_CHARS)
857 )
858 }
859
860 #[allow(dead_code)]
861 fn _assert_var_handle_shape(_: Option<VarHandle>) {}
862
863 #[cfg(test)]
864 mod tests {
865 use super::*;
866 use crate::models::{ContentBlock, Message, SystemPrompt};
867 use crate::rlm::session::SessionObjectSnapshot;
868 use crate::tools::handle::HandleReadTool;
869 use crate::tools::spec::ToolContext;
870 use std::path::PathBuf;
871
872 fn ctx() -> ToolContext {
873 ToolContext::new(".")
874 }
875
876 fn ctx_with_session_objects() -> ToolContext {
877 ToolContext::new(".").with_session_objects(SessionObjectSnapshot::new(
878 "session-1".to_string(),
879 "deepseek-v4-pro".to_string(),
880 PathBuf::from("."),
881 Some(SystemPrompt::Text("You are CodeWhale.".to_string())),
882 vec![
883 Message {
884 role: "user".to_string(),
885 content: vec![ContentBlock::Text {
886 text: "Please inspect the RLM surface.".to_string(),
887 cache_control: None,
888 }],
889 },
890 Message {
891 role: "assistant".to_string(),
892 content: vec![ContentBlock::Text {
893 text: "I will use symbolic session objects.".to_string(),
894 cache_control: None,
895 }],
896 },
897 ],
898 ))
899 }
900
901 #[test]
902 fn schema_uses_new_tool_names() {
903 assert_eq!(
904 RlmTool::alias("rlm_session_objects", "session_objects", None).name(),
905 "rlm_session_objects"
906 );
907 assert_eq!(RlmTool::alias("rlm_open", "open", None).name(), "rlm_open");
908 assert_eq!(RlmTool::alias("rlm_eval", "eval", None).name(), "rlm_eval");
909 assert_eq!(
910 RlmTool::alias("rlm_configure", "configure", None).name(),
911 "rlm_configure"
912 );
913 assert_eq!(
914 RlmTool::alias("rlm_close", "close", None).name(),
915 "rlm_close"
916 );
917 }
918
919 #[test]
920 fn rlm_tool_is_compatibility_only_not_model_visible() {
921 let canonical = RlmTool::new("rlm", None);
922 assert!(!canonical.model_visible());
923 assert_eq!(canonical.name(), "rlm");
924 let actions = canonical.input_schema()["properties"]["action"]["enum"]
925 .as_array()
926 .expect("action enum")
927 .clone();
928 for action in ["session_objects", "open", "eval", "configure", "close"] {
929 assert!(
930 actions.iter().any(|value| value.as_str() == Some(action)),
931 "canonical schema must offer action {action}"
932 );
933 }
934
935 for alias in [
936 RlmTool::alias("rlm_session_objects", "session_objects", None),
937 RlmTool::alias("rlm_open", "open", None),
938 RlmTool::alias("rlm_eval", "eval", None),
939 RlmTool::alias("rlm_configure", "configure", None),
940 RlmTool::alias("rlm_close", "close", None),
941 ] {
942 assert!(
943 !alias.model_visible(),
944 "compatibility alias {} must stay hidden",
945 alias.name()
946 );
947 }
948 }
949
950 #[test]
951 fn kernel_failure_result_retains_child_usage_receipt() {
952 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
953 None,
954 crate::config::ApiProvider::Deepseek,
955 "deepseek-rlm",
956 DEFAULT_CHILD_MODEL,
957 Some(crate::config::ApiProvider::Deepseek.default_base_url()),
958 chrono::Utc::now(),
959 );
960 let usage = crate::models::Usage {
961 input_tokens: 23,
962 output_tokens: 5,
963 reasoning_replay_tokens: Some(7),
964 ..Default::default()
965 };
966 let result = rlm_kernel_error_result(
967 "kernel stdout closed",
968 Duration::from_millis(11),
969 &route,
970 &usage,
971 );
972
973 assert!(!result.success);
974 let metadata = result
975 .metadata
976 .expect("usage metadata on failed tool result");
977 assert_eq!(
978 crate::cost_status::child_route_envelope_from_metadata(&metadata),
979 Some(route)
980 );
981 assert_eq!(
982 crate::cost_status::child_usage_from_metadata(&metadata),
983 Some(usage)
984 );
985 }
986
987 #[test]
988 fn rlm_eval_requires_approval() {
989 let tool = RlmTool::alias("rlm_eval", "eval", None);
990 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Required);
991 assert!(
992 tool.capabilities()
993 .contains(&ToolCapability::RequiresApproval)
994 );
995
996 // Approval routing on the canonical tool: only eval requires it.
997 let canonical = RlmTool::new("rlm", None);
998 assert_eq!(
999 canonical.approval_requirement_for(&json!({"action": "eval"})),
1000 ApprovalRequirement::Required
1001 );
1002 assert_eq!(
1003 canonical.approval_requirement_for(&json!({"action": "open"})),
1004 ApprovalRequirement::Auto
1005 );
1006 assert_eq!(
1007 canonical.approval_requirement_for(&json!({"action": "session_objects"})),
1008 ApprovalRequirement::Auto
1009 );
1010 }
1011
1012 #[test]
1013 fn read_only_and_parallel_flags_match_legacy_contract() {
1014 // Legacy: session_objects was parallel-friendly read-only; open carried
1015 // ExecutesCode (not read-only) with Auto approval; eval required approval.
1016 let session_objects = RlmTool::alias("rlm_session_objects", "session_objects", None);
1017 assert!(session_objects.supports_parallel());
1018 assert!(session_objects.is_read_only_for(&json!({})));
1019
1020 let open = RlmTool::alias("rlm_open", "open", None);
1021 assert!(!open.is_read_only_for(&json!({})));
1022 assert_eq!(open.approval_requirement(), ApprovalRequirement::Auto);
1023
1024 let canonical = RlmTool::new("rlm", None);
1025 assert!(canonical.supports_parallel_for(&json!({"action": "session_objects"})));
1026 assert!(!canonical.supports_parallel_for(&json!({"action": "eval"})));
1027 assert!(canonical.is_read_only_for(&json!({"action": "configure"})));
1028 assert!(!canonical.is_read_only_for(&json!({"action": "open"})));
1029 assert!(!canonical.is_read_only_for(&json!({"action": "eval"})));
1030 }
1031
1032 #[test]
1033 fn canonical_rejects_unknown_or_missing_action() {
1034 let tool = RlmTool::new("rlm", None);
1035 let err = tool
1036 .resolve_action(&json!({}))
1037 .expect_err("missing action must fail");
1038 assert!(err.to_string().contains("missing `action`"));
1039 let err = tool
1040 .resolve_action(&json!({"action": "explode"}))
1041 .expect_err("unknown action must fail");
1042 assert!(err.to_string().contains("invalid action"));
1043 }
1044
1045 #[test]
1046 fn rlm_open_source_count_ignores_empty_string_defaults() {
1047 assert_eq!(
1048 rlm_open_source_count(
1049 &json!({"name": "url-doc", "file_path": "", "content": "", "url": "https://example.com/doc"})
1050 ),
1051 1
1052 );
1053 assert_eq!(
1054 rlm_open_source_count(
1055 &json!({"name": "inline-doc", "file_path": "", "content": "body", "url": ""})
1056 ),
1057 1
1058 );
1059 assert_eq!(
1060 rlm_open_source_count(&json!({"content": "body", "url": "https://example.com/doc"})),
1061 2
1062 );
1063 assert_eq!(
1064 rlm_open_source_count(
1065 &json!({"content": "body", "session_object": "session://active/system_prompt"})
1066 ),
1067 2
1068 );
1069 }
1070
1071 #[tokio::test]
1072 async fn rlm_session_objects_lists_active_prompt_object() {
1073 let ctx = ctx_with_session_objects();
1074 let result = RlmTool::alias("rlm_session_objects", "session_objects", None)
1075 .execute(json!({}), &ctx)
1076 .await
1077 .expect("list session objects");
1078 let body: Value = serde_json::from_str(&result.content).expect("json");
1079 let objects = body["objects"].as_array().expect("objects array");
1080
1081 assert!(objects.iter().any(|object| {
1082 object["id"] == "session://active/system_prompt" && object["kind"] == "system_prompt"
1083 }));
1084 assert!(objects.iter().any(|object| {
1085 object["id"] == "session://active/messages/0" && object["kind"] == "message"
1086 }));
1087 }
1088
1089 #[tokio::test]
1090 async fn rlm_open_loads_active_session_prompt_object() {
1091 let ctx = ctx_with_session_objects();
1092 let open = RlmTool::alias("rlm_open", "open", None)
1093 .execute(
1094 json!({"name": "active_prompt", "session_object": "session://active/system_prompt"}),
1095 &ctx,
1096 )
1097 .await
1098 .expect("open prompt object");
1099 let open_json: Value = serde_json::from_str(&open.content).expect("open json");
1100 assert_eq!(open_json["type"], "session_object:system_prompt");
1101 assert!(
1102 open_json["preview_500"]
1103 .as_str()
1104 .unwrap()
1105 .contains("CodeWhale")
1106 );
1107
1108 RlmTool::alias("rlm_close", "close", None)
1109 .execute(json!({"name": "active_prompt"}), &ctx)
1110 .await
1111 .expect("close");
1112 }
1113
1114 #[tokio::test]
1115 async fn rlm_open_loads_transcript_message_object() {
1116 let ctx = ctx_with_session_objects();
1117 let open = RlmTool::alias("rlm_open", "open", None)
1118 .execute(
1119 json!({"name": "first_message", "session_object": "session://active/messages/0"}),
1120 &ctx,
1121 )
1122 .await
1123 .expect("open transcript slice");
1124 let open_json: Value = serde_json::from_str(&open.content).expect("open json");
1125 assert_eq!(open_json["type"], "session_object:message");
1126 assert!(
1127 open_json["preview_500"]
1128 .as_str()
1129 .unwrap()
1130 .contains("RLM surface")
1131 );
1132
1133 RlmTool::alias("rlm_close", "close", None)
1134 .execute(json!({"name": "first_message"}), &ctx)
1135 .await
1136 .expect("close");
1137 }
1138
1139 #[tokio::test]
1140 async fn rlm_open_ignores_blank_source_defaults_from_schema_fillers() {
1141 let ctx = ctx();
1142 RlmTool::alias("rlm_open", "open", None)
1143 .execute(
1144 json!({"name": "blank-defaults", "file_path": "", "content": "body", "url": ""}),
1145 &ctx,
1146 )
1147 .await
1148 .expect("open with blank sibling source fields");
1149
1150 RlmTool::alias("rlm_close", "close", None)
1151 .execute(json!({"name": "blank-defaults"}), &ctx)
1152 .await
1153 .expect("close");
1154 }
1155
1156 #[tokio::test]
1157 async fn rlm_open_misnamed_source_field_gets_did_you_mean_hint() {
1158 // #2655: a wrong source field name yields actionable guidance, not just
1159 // the canonical "provide exactly one" message.
1160 let ctx = ctx();
1161 let err = RlmTool::alias("rlm_open", "open", None)
1162 .execute(json!({"name": "doc", "prompt": "summarize this"}), &ctx)
1163 .await
1164 .expect_err("misnamed source field should fail");
1165 let msg = err.to_string();
1166 assert!(msg.contains("file_path"), "names the real fields: {msg}");
1167 assert!(
1168 msg.contains("`url`, or `session_object`"),
1169 "names session_object in the valid source field list: {msg}"
1170 );
1171 assert!(msg.contains("prompt"), "echoes the wrong field: {msg}");
1172 }
1173
1174 #[tokio::test]
1175 async fn rlm_eval_missing_code_explains_raw_python() {
1176 // #2655: the missing-code error should teach the tool, with an example.
1177 let ctx = ctx();
1178 let err = RlmTool::alias("rlm_eval", "eval", None)
1179 .execute(json!({"name": "doc"}), &ctx)
1180 .await
1181 .expect_err("missing code should fail");
1182 let msg = err.to_string();
1183 assert!(msg.contains("raw Python"), "explains it runs Python: {msg}");
1184 assert!(
1185 msg.contains("print(len(content))") || msg.contains("FINAL"),
1186 "includes an example: {msg}"
1187 );
1188 }
1189
1190 #[test]
1191 fn rlm_eval_schema_names_the_runtime_content_variable() {
1192 let schema = RlmTool::alias("rlm_eval", "eval", None).input_schema();
1193 let description = schema["properties"]["code"]["description"]
1194 .as_str()
1195 .expect("rlm_eval code description");
1196
1197 assert!(description.contains("`content`"));
1198 assert!(description.contains("print(len(content))"));
1199 assert!(!description.contains("SOURCE"));
1200 }
1201
1202 #[tokio::test]
1203 async fn rlm_session_open_eval_close_lifecycle() {
1204 let ctx = ctx();
1205 RlmTool::alias("rlm_open", "open", None)
1206 .execute(
1207 json!({"name": "sample", "content": "alpha\nbeta\ngamma"}),
1208 &ctx,
1209 )
1210 .await
1211 .expect("open");
1212
1213 let eval = RlmTool::alias("rlm_eval", "eval", None)
1214 .execute(json!({"name": "sample", "code": "print('ok')"}), &ctx)
1215 .await
1216 .expect("eval");
1217 let eval_json: Value = serde_json::from_str(&eval.content).expect("eval json");
1218 let stdout_preview = eval_json["stdout_preview"]
1219 .as_str()
1220 .expect("stdout_preview")
1221 .replace("\r\n", "\n");
1222 assert_eq!(stdout_preview, "ok\n");
1223
1224 let close = RlmTool::alias("rlm_close", "close", None)
1225 .execute(json!({"name": "sample"}), &ctx)
1226 .await
1227 .expect("close");
1228 assert!(close.content.contains("sample"));
1229 }
1230
1231 #[tokio::test]
1232 async fn rlm_canonical_action_routing_runs_full_lifecycle() {
1233 // The visible surface: one `rlm` tool, action-parameterized.
1234 let ctx = ctx();
1235 let tool = RlmTool::new("rlm", None);
1236 tool.execute(
1237 json!({"action": "open", "name": "canonical", "content": "body"}),
1238 &ctx,
1239 )
1240 .await
1241 .expect("open via canonical action");
1242
1243 let eval = tool
1244 .execute(
1245 json!({"action": "eval", "name": "canonical", "code": "print('ok')"}),
1246 &ctx,
1247 )
1248 .await
1249 .expect("eval via canonical action");
1250 let eval_json: Value = serde_json::from_str(&eval.content).expect("eval json");
1251 let stdout_preview = eval_json["stdout_preview"]
1252 .as_str()
1253 .expect("stdout_preview")
1254 .replace("\r\n", "\n");
1255 assert_eq!(stdout_preview, "ok\n");
1256
1257 let close = tool
1258 .execute(json!({"action": "close", "name": "canonical"}), &ctx)
1259 .await
1260 .expect("close via canonical action");
1261 assert!(close.content.contains("canonical"));
1262 }
1263
1264 #[tokio::test]
1265 async fn rlm_eval_final_returns_handle() {
1266 let ctx = ctx();
1267 RlmTool::alias("rlm_open", "open", None)
1268 .execute(json!({"name": "finals", "content": "body"}), &ctx)
1269 .await
1270 .expect("open");
1271
1272 let eval = RlmTool::alias("rlm_eval", "eval", None)
1273 .execute(
1274 json!({"name": "finals", "code": "finalize('done', confidence=0.8)"}),
1275 &ctx,
1276 )
1277 .await
1278 .expect("eval");
1279 let eval_json: Value = serde_json::from_str(&eval.content).expect("eval json");
1280 assert_eq!(eval_json["final"]["kind"], "var_handle");
1281 assert_eq!(eval_json["final"]["name"], "final_1");
1282 assert_eq!(eval_json["confidence"], 0.8);
1283
1284 RlmTool::alias("rlm_close", "close", None)
1285 .execute(json!({"name": "finals"}), &ctx)
1286 .await
1287 .expect("close");
1288 }
1289
1290 #[tokio::test]
1291 async fn rlm_eval_final_preserves_json_handle() {
1292 let ctx = ctx();
1293 RlmTool::alias("rlm_open", "open", None)
1294 .execute(json!({"name": "json-final", "content": "body"}), &ctx)
1295 .await
1296 .expect("open");
1297
1298 let eval = RlmTool::alias("rlm_eval", "eval", None)
1299 .execute(
1300 json!({"name": "json-final", "code": "finalize({'answer': 42, 'items': ['a', 'b']})"}),
1301 &ctx,
1302 )
1303 .await
1304 .expect("eval");
1305 let eval_json: Value = serde_json::from_str(&eval.content).expect("eval json");
1306 assert_eq!(eval_json["final"]["kind"], "var_handle");
1307 assert_eq!(eval_json["final"]["type"], "dict");
1308 assert_eq!(eval_json["final"]["length"], 2);
1309
1310 let read = HandleReadTool
1311 .execute(
1312 json!({"handle": eval_json["final"].clone(), "jsonpath": "$.items[*]"}),
1313 &ctx,
1314 )
1315 .await
1316 .expect("read final handle");
1317 let read_json: Value = serde_json::from_str(&read.content).expect("read json");
1318 assert_eq!(read_json["matches"], json!(["a", "b"]));
1319
1320 RlmTool::alias("rlm_close", "close", None)
1321 .execute(json!({"name": "json-final"}), &ctx)
1322 .await
1323 .expect("close");
1324 }
1325
1326 #[tokio::test]
1327 async fn rlm_configure_metadata_omits_stdout() {
1328 let ctx = ctx();
1329 RlmTool::alias("rlm_open", "open", None)
1330 .execute(json!({"name": "quiet", "content": "body"}), &ctx)
1331 .await
1332 .expect("open");
1333 RlmTool::alias("rlm_configure", "configure", None)
1334 .execute(
1335 json!({"name": "quiet", "output_feedback": "metadata", "sub_rlm_max_depth": 99}),
1336 &ctx,
1337 )
1338 .await
1339 .expect("configure");
1340
1341 let eval = RlmTool::alias("rlm_eval", "eval", None)
1342 .execute(json!({"name": "quiet", "code": "print('hidden')"}), &ctx)
1343 .await
1344 .expect("eval");
1345 let eval_json: Value = serde_json::from_str(&eval.content).expect("eval json");
1346 assert!(eval_json.get("stdout_preview").is_none());
1347
1348 RlmTool::alias("rlm_close", "close", None)
1349 .execute(json!({"name": "quiet"}), &ctx)
1350 .await
1351 .expect("close");
1352 }
1353 }
1354
1354 lines RUST