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