返回 CodeWhale
handle.rs
根目录 / crates / tui / src / tools / handle.rs
1 //! Symbolic handle storage and bounded reads.
2 //!
3 //! `var_handle` is the shared protocol that lets expensive environments
4 //! (RLM sessions, sub-agent transcripts, large artifacts) hand the parent a
5 //! small symbolic reference instead of copying the whole payload into the
6 //! parent transcript.
7
8 use std::collections::HashMap;
9 use std::sync::Arc;
10
11 use async_trait::async_trait;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14 use tokio::sync::Mutex;
15
16 use crate::tools::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 };
19
20 const DEFAULT_MAX_CHARS: usize = 12_000;
21 const HARD_MAX_CHARS: usize = 50_000;
22 #[allow(dead_code)] // Used by producers as they begin returning var_handle records.
23 const REPR_PREVIEW_CHARS: usize = 160;
24
25 pub type SharedHandleStore = Arc<Mutex<HandleStore>>;
26
27 #[must_use]
28 pub fn new_shared_handle_store() -> SharedHandleStore {
29 Arc::new(Mutex::new(HandleStore::default()))
30 }
31
32 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33 pub struct VarHandle {
34 pub kind: String,
35 pub session_id: String,
36 pub name: String,
37 #[serde(rename = "type")]
38 pub type_name: String,
39 pub length: usize,
40 pub repr_preview: String,
41 pub sha256: String,
42 }
43
44 impl VarHandle {
45 #[must_use]
46 pub fn key(&self) -> HandleKey {
47 HandleKey {
48 session_id: self.session_id.clone(),
49 name: self.name.clone(),
50 }
51 }
52 }
53
54 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
55 pub struct HandleKey {
56 pub session_id: String,
57 pub name: String,
58 }
59
60 #[derive(Debug, Clone)]
61 pub struct HandleRecord {
62 pub handle: VarHandle,
63 pub value: HandleValue,
64 }
65
66 #[allow(dead_code)] // Producers land in later v0.8.33 slices; handle_read is first.
67 #[derive(Debug, Clone)]
68 pub enum HandleValue {
69 Text(String),
70 Json(Value),
71 }
72
73 #[allow(dead_code)] // Foundation methods used by upcoming RLM/agent session producers.
74 impl HandleValue {
75 fn length(&self) -> usize {
76 match self {
77 Self::Text(text) => text.chars().count(),
78 Self::Json(Value::Array(items)) => items.len(),
79 Self::Json(Value::Object(map)) => map.len(),
80 Self::Json(value) => value.to_string().chars().count(),
81 }
82 }
83
84 fn type_name(&self) -> String {
85 match self {
86 Self::Text(_) => "str".to_string(),
87 Self::Json(Value::Array(_)) => "list".to_string(),
88 Self::Json(Value::Object(_)) => "dict".to_string(),
89 Self::Json(Value::String(_)) => "str".to_string(),
90 Self::Json(Value::Bool(_)) => "bool".to_string(),
91 Self::Json(Value::Number(_)) => "number".to_string(),
92 Self::Json(Value::Null) => "null".to_string(),
93 }
94 }
95
96 fn stable_bytes(&self) -> Vec<u8> {
97 match self {
98 Self::Text(text) => text.as_bytes().to_vec(),
99 Self::Json(value) => serde_json::to_vec(value).unwrap_or_default(),
100 }
101 }
102
103 fn repr_preview(&self) -> String {
104 match self {
105 Self::Text(text) => truncate_chars(text, REPR_PREVIEW_CHARS),
106 Self::Json(value) => truncate_chars(&value.to_string(), REPR_PREVIEW_CHARS),
107 }
108 }
109 }
110
111 #[derive(Debug, Default)]
112 pub struct HandleStore {
113 records: HashMap<HandleKey, HandleRecord>,
114 }
115
116 #[allow(dead_code)] // Insertors are for producer tools; this PR wires the reader first.
117 impl HandleStore {
118 #[must_use]
119 pub fn insert_text(
120 &mut self,
121 session_id: impl Into<String>,
122 name: impl Into<String>,
123 text: impl Into<String>,
124 ) -> VarHandle {
125 self.insert(session_id, name, HandleValue::Text(text.into()))
126 }
127
128 #[must_use]
129 pub fn insert_json(
130 &mut self,
131 session_id: impl Into<String>,
132 name: impl Into<String>,
133 value: Value,
134 ) -> VarHandle {
135 self.insert(session_id, name, HandleValue::Json(value))
136 }
137
138 #[must_use]
139 pub fn get(&self, handle: &VarHandle) -> Option<&HandleRecord> {
140 self.records.get(&handle.key())
141 }
142
143 /// Remove all handles for `session_id`. Called when an agent's records
144 /// are retired so resident transcript payloads are freed without waiting
145 /// for a full session reset (#3885).
146 pub fn evict_session(&mut self, session_id: &str) {
147 self.records.retain(|key, _| key.session_id != session_id);
148 }
149
150 fn insert(
151 &mut self,
152 session_id: impl Into<String>,
153 name: impl Into<String>,
154 value: HandleValue,
155 ) -> VarHandle {
156 let session_id = session_id.into();
157 let name = name.into();
158 let handle = VarHandle {
159 kind: "var_handle".to_string(),
160 session_id: session_id.clone(),
161 name: name.clone(),
162 type_name: value.type_name(),
163 length: value.length(),
164 repr_preview: value.repr_preview(),
165 sha256: sha256_hex(&value.stable_bytes()),
166 };
167 let key = HandleKey { session_id, name };
168 self.records.insert(
169 key,
170 HandleRecord {
171 handle: handle.clone(),
172 value,
173 },
174 );
175 handle
176 }
177 }
178
179 pub struct HandleReadTool;
180
181 #[async_trait]
182 impl ToolSpec for HandleReadTool {
183 fn name(&self) -> &'static str {
184 "handle_read"
185 }
186
187 fn description(&self) -> &'static str {
188 "Read a bounded projection from a var_handle returned by tools such \
189 as RLM sessions or sub-agents. This does not read artifact ids \
190 (`art_...`), tool-call ids (`call_...`), SHA refs, or files; use \
191 retrieve_tool_result for spilled tool results/artifacts and \
192 File action=\"read\" for workspace files. Provide \
193 exactly one projection: `slice` for char/line slices, `range` for \
194 one-based line ranges, `count` for metadata counts, or `jsonpath` \
195 for a small JSON-path projection. This retrieves from the handle's \
196 backing environment instead of asking the parent transcript to hold \
197 the full payload."
198 }
199
200 fn input_schema(&self) -> Value {
201 json!({
202 "type": "object",
203 "required": ["handle"],
204 "properties": {
205 "handle": {
206 "description": "A var_handle object, or a compact `session_id/name` string. Not an `art_...`, `call_...`, SHA, or file path ref.",
207 "oneOf": [
208 {
209 "type": "object",
210 "required": ["kind", "session_id", "name"],
211 "properties": {
212 "kind": { "type": "string", "const": "var_handle" },
213 "session_id": { "type": "string" },
214 "name": { "type": "string" },
215 "type": { "type": "string" },
216 "length": { "type": "integer" },
217 "repr_preview": { "type": "string" },
218 "sha256": { "type": "string" }
219 }
220 },
221 { "type": "string" }
222 ]
223 },
224 "slice": {
225 "type": "object",
226 "description": "Zero-based half-open slice over chars or lines.",
227 "properties": {
228 "start": { "type": "integer", "minimum": 0 },
229 "end": { "type": "integer", "minimum": 0 },
230 "unit": { "type": "string", "enum": ["chars", "lines"], "default": "chars" }
231 }
232 },
233 "range": {
234 "type": "object",
235 "description": "One-based inclusive line range.",
236 "required": ["start", "end"],
237 "properties": {
238 "start": { "type": "integer", "minimum": 1 },
239 "end": { "type": "integer", "minimum": 1 }
240 }
241 },
242 "count": {
243 "type": "boolean",
244 "description": "Return counts for the handle payload."
245 },
246 "jsonpath": {
247 "type": "string",
248 "description": "Small JSONPath subset: $, .field, [index], [*], and ['field']."
249 },
250 "introspect": {
251 "type": "boolean",
252 "description": "Return supported projections, size hints, and copy-pasteable examples for this handle."
253 },
254 "max_chars": {
255 "type": "integer",
256 "description": "Maximum characters to return in this projection. Defaults to 12000; hard-capped at 50000."
257 }
258 }
259 })
260 }
261
262 fn capabilities(&self) -> Vec<ToolCapability> {
263 vec![ToolCapability::ReadOnly]
264 }
265
266 fn approval_requirement(&self) -> ApprovalRequirement {
267 ApprovalRequirement::Auto
268 }
269
270 fn supports_parallel(&self) -> bool {
271 true
272 }
273
274 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
275 let handle = parse_handle(
276 input
277 .get("handle")
278 .ok_or_else(|| ToolError::missing_field("handle"))?,
279 )?;
280 let projection = parse_projection(&input)?;
281 let max_chars = input
282 .get("max_chars")
283 .and_then(Value::as_u64)
284 .map(|n| (n as usize).min(HARD_MAX_CHARS))
285 .unwrap_or(DEFAULT_MAX_CHARS);
286
287 let store = context.runtime.handle_store.lock().await;
288 let record = store.get(&handle).ok_or_else(|| {
289 ToolError::invalid_input(format!(
290 "handle_read: no payload found for handle {}/{}",
291 handle.session_id, handle.name
292 ))
293 })?;
294 if !handle.sha256.is_empty() && handle.sha256 != record.handle.sha256 {
295 return Err(ToolError::invalid_input(
296 "handle_read: handle sha256 does not match stored payload",
297 ));
298 }
299
300 let output = match projection {
301 Projection::Count => count_projection(record),
302 Projection::Slice { start, end, unit } => {
303 slice_projection(record, start, end, unit, max_chars)
304 }
305 Projection::Range { start, end } => {
306 line_range_projection(record, start, end, max_chars)
307 }
308 Projection::JsonPath(path) => jsonpath_projection(record, &path, max_chars)?,
309 Projection::Introspect => introspect_projection(record),
310 };
311
312 ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string()))
313 }
314 }
315
316 #[derive(Debug, Clone, Copy)]
317 enum SliceUnit {
318 Chars,
319 Lines,
320 }
321
322 enum Projection {
323 Count,
324 Slice {
325 start: usize,
326 end: Option<usize>,
327 unit: SliceUnit,
328 },
329 Range {
330 start: usize,
331 end: usize,
332 },
333 JsonPath(String),
334 Introspect,
335 }
336
337 fn parse_handle(value: &Value) -> Result<VarHandle, ToolError> {
338 if let Some(raw) = value.as_str() {
339 if looks_like_tool_result_ref(raw) {
340 return Err(ToolError::invalid_input(
341 "handle_read only accepts var_handle objects or `session_id/name` strings. \
342 This looks like an artifact/tool-result ref; use `retrieve_tool_result` instead.",
343 ));
344 }
345 let Some((session_id, name)) = raw.rsplit_once('/') else {
346 return Err(ToolError::invalid_input(
347 "handle_read: string handles must use `session_id/name`. \
348 For `art_...`, `call_...`, SHA, or file refs, use `retrieve_tool_result`.",
349 ));
350 };
351 return Ok(VarHandle {
352 kind: "var_handle".to_string(),
353 session_id: session_id.to_string(),
354 name: name.to_string(),
355 type_name: String::new(),
356 length: 0,
357 repr_preview: String::new(),
358 sha256: String::new(),
359 });
360 }
361
362 let handle: VarHandle = serde_json::from_value(value.clone()).map_err(|e| {
363 ToolError::invalid_input(format!("handle_read: invalid var_handle object: {e}"))
364 })?;
365 if handle.kind != "var_handle" {
366 return Err(ToolError::invalid_input(
367 "handle_read: handle.kind must be `var_handle`",
368 ));
369 }
370 if handle.session_id.trim().is_empty() || handle.name.trim().is_empty() {
371 return Err(ToolError::invalid_input(
372 "handle_read: handle.session_id and handle.name must be non-empty",
373 ));
374 }
375 Ok(handle)
376 }
377
378 fn looks_like_tool_result_ref(raw: &str) -> bool {
379 let trimmed = raw.trim();
380 let sha_candidate = trimmed
381 .strip_prefix("sha:")
382 .or_else(|| trimmed.strip_prefix("sha_"))
383 .unwrap_or(trimmed);
384 trimmed.starts_with("art_")
385 || trimmed.starts_with("call_")
386 || trimmed.starts_with("tool_result:")
387 || trimmed.ends_with(".txt")
388 || crate::tools::truncate::is_valid_sha256(&sha_candidate.to_ascii_lowercase())
389 }
390
391 fn parse_projection(input: &Value) -> Result<Projection, ToolError> {
392 let mut count = 0usize;
393 count += usize::from(input.get("slice").is_some());
394 count += usize::from(input.get("range").is_some());
395 count += usize::from(input.get("count").and_then(Value::as_bool).unwrap_or(false));
396 count += usize::from(input.get("jsonpath").is_some());
397 count += usize::from(
398 input
399 .get("introspect")
400 .and_then(Value::as_bool)
401 .unwrap_or(false),
402 );
403 if count != 1 {
404 return Err(ToolError::invalid_input(projection_usage_hint()));
405 }
406
407 if input
408 .get("introspect")
409 .and_then(Value::as_bool)
410 .unwrap_or(false)
411 {
412 return Ok(Projection::Introspect);
413 }
414 if input.get("count").and_then(Value::as_bool).unwrap_or(false) {
415 return Ok(Projection::Count);
416 }
417 if let Some(path) = input.get("jsonpath") {
418 let path = path
419 .as_str()
420 .ok_or_else(|| ToolError::invalid_input("handle_read: jsonpath must be a string"))?
421 .trim();
422 if path.is_empty() {
423 return Err(ToolError::invalid_input(
424 "handle_read: jsonpath must not be empty",
425 ));
426 }
427 return Ok(Projection::JsonPath(path.to_string()));
428 }
429 if let Some(slice) = input.get("slice") {
430 let start = slice.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
431 let end = slice.get("end").and_then(Value::as_u64).map(|n| n as usize);
432 if let Some(end) = end
433 && end < start
434 {
435 return Err(ToolError::invalid_input(
436 "handle_read: slice.end must be greater than or equal to slice.start",
437 ));
438 }
439 let unit = match slice.get("unit").and_then(Value::as_str).unwrap_or("chars") {
440 "chars" => SliceUnit::Chars,
441 "lines" => SliceUnit::Lines,
442 other => {
443 return Err(ToolError::invalid_input(format!(
444 "handle_read: unsupported slice.unit `{other}`"
445 )));
446 }
447 };
448 return Ok(Projection::Slice { start, end, unit });
449 }
450 let range = input
451 .get("range")
452 .ok_or_else(|| ToolError::invalid_input("handle_read: missing projection"))?;
453 let start = range
454 .get("start")
455 .and_then(Value::as_u64)
456 .ok_or_else(|| ToolError::missing_field("range.start"))? as usize;
457 let end = range
458 .get("end")
459 .and_then(Value::as_u64)
460 .ok_or_else(|| ToolError::missing_field("range.end"))? as usize;
461 if start == 0 || end == 0 || end < start {
462 return Err(ToolError::invalid_input(
463 "handle_read: range is one-based inclusive and end must be >= start",
464 ));
465 }
466 Ok(Projection::Range { start, end })
467 }
468
469 fn projection_usage_hint() -> String {
470 "handle_read: provide exactly one projection: `slice`, `range`, `count: true`, `jsonpath`, or `introspect: true`. \
471 Examples: {\"handle\":{\"kind\":\"var_handle\",\"session_id\":\"rlm:abc\",\"name\":\"final_1\"},\"slice\":{\"start\":0,\"end\":500}}; \
472 {\"handle\":\"rlm:abc/final_1\",\"count\":true}; \
473 {\"handle\":\"rlm:abc/final_1\",\"introspect\":true}."
474 .to_string()
475 }
476
477 fn count_projection(record: &HandleRecord) -> Value {
478 match &record.value {
479 HandleValue::Text(text) => json!({
480 "handle": record.handle,
481 "projection": "count",
482 "chars": text.chars().count(),
483 "lines": text.lines().count(),
484 "bytes": text.len(),
485 }),
486 HandleValue::Json(value) => {
487 let bytes = {
488 let mut cw = crate::utils::CountingWriter::new();
489 let _ = serde_json::to_writer(&mut cw, value);
490 cw.count()
491 };
492 json!({
493 "handle": record.handle,
494 "projection": "count",
495 "json_type": json_type(value),
496 "length": record.handle.length,
497 "bytes": bytes,
498 })
499 }
500 }
501 }
502
503 fn introspect_projection(record: &HandleRecord) -> Value {
504 let string_handle = format!("{}/{}", record.handle.session_id, record.handle.name);
505 let object_handle = json!(record.handle.clone());
506 let mut projections = vec![
507 json!({"name": "count", "example": {"handle": string_handle, "count": true}}),
508 json!({"name": "slice_chars", "example": {"handle": object_handle.clone(), "slice": {"start": 0, "end": 500}}}),
509 json!({"name": "range_lines", "example": {"handle": object_handle.clone(), "range": {"start": 1, "end": 20}}}),
510 ];
511 if matches!(record.value, HandleValue::Json(_)) {
512 projections.push(
513 json!({"name": "jsonpath", "example": {"handle": object_handle, "jsonpath": "$"}}),
514 );
515 }
516
517 json!({
518 "handle": record.handle,
519 "projection": "introspect",
520 "value_type": match &record.value {
521 HandleValue::Text(_) => "text",
522 HandleValue::Json(value) => json_type(value),
523 },
524 "length": record.handle.length,
525 "repr_preview": record.handle.repr_preview,
526 "projections": projections,
527 })
528 }
529
530 fn slice_projection(
531 record: &HandleRecord,
532 start: usize,
533 end: Option<usize>,
534 unit: SliceUnit,
535 max_chars: usize,
536 ) -> Value {
537 let text = record_text(record);
538 match unit {
539 SliceUnit::Chars => {
540 let total = text.chars().count();
541 let end = end.unwrap_or(total).min(total);
542 let raw = char_slice(&text, start.min(total), end);
543 bounded_text_projection(
544 record,
545 "slice",
546 raw,
547 max_chars,
548 json!({
549 "unit": "chars",
550 "start": start.min(total),
551 "end": end,
552 "total_chars": total,
553 }),
554 )
555 }
556 SliceUnit::Lines => {
557 let lines: Vec<&str> = text.lines().collect();
558 let total = lines.len();
559 let end = end.unwrap_or(total).min(total);
560 let raw = if start >= end {
561 String::new()
562 } else {
563 lines[start.min(total)..end].join("\n")
564 };
565 bounded_text_projection(
566 record,
567 "slice",
568 raw,
569 max_chars,
570 json!({
571 "unit": "lines",
572 "start": start.min(total),
573 "end": end,
574 "total_lines": total,
575 }),
576 )
577 }
578 }
579 }
580
581 fn line_range_projection(
582 record: &HandleRecord,
583 start: usize,
584 end: usize,
585 max_chars: usize,
586 ) -> Value {
587 let text = record_text(record);
588 let lines: Vec<&str> = text.lines().collect();
589 let total = lines.len();
590 let zero_start = start.saturating_sub(1).min(total);
591 let zero_end = end.min(total);
592 let raw = if zero_start >= zero_end {
593 String::new()
594 } else {
595 lines[zero_start..zero_end].join("\n")
596 };
597 bounded_text_projection(
598 record,
599 "range",
600 raw,
601 max_chars,
602 json!({
603 "start": start,
604 "end": end,
605 "shown_start": zero_start + 1,
606 "shown_end": zero_end,
607 "total_lines": total,
608 }),
609 )
610 }
611
612 fn jsonpath_projection(
613 record: &HandleRecord,
614 path: &str,
615 max_chars: usize,
616 ) -> Result<Value, ToolError> {
617 let HandleValue::Json(value) = &record.value else {
618 return Err(ToolError::invalid_input(
619 "handle_read: jsonpath projection requires a JSON handle",
620 ));
621 };
622 let matches = query_jsonpath(value, path)
623 .map_err(|e| ToolError::invalid_input(format!("handle_read: {e}")))?;
624 let mut payload = json!({
625 "handle": record.handle,
626 "projection": "jsonpath",
627 "jsonpath": path,
628 "count": matches.len(),
629 "matches": matches,
630 "truncated": false,
631 });
632 let rendered = serde_json::to_string(&payload).unwrap_or_default();
633 if rendered.chars().count() > max_chars {
634 payload["matches"] = json!([]);
635 payload["preview"] = json!(truncate_chars(&rendered, max_chars));
636 payload["truncated"] = json!(true);
637 }
638 Ok(payload)
639 }
640
641 fn bounded_text_projection(
642 record: &HandleRecord,
643 projection: &str,
644 raw: String,
645 max_chars: usize,
646 extra: Value,
647 ) -> Value {
648 let raw_chars = raw.chars().count();
649 let content = truncate_chars(&raw, max_chars);
650 let shown_chars = content.chars().count();
651 json!({
652 "handle": record.handle,
653 "projection": projection,
654 "content": content,
655 "truncated": shown_chars < raw_chars,
656 "shown_chars": shown_chars,
657 "omitted_chars": raw_chars.saturating_sub(shown_chars),
658 "meta": extra,
659 })
660 }
661
662 fn record_text(record: &HandleRecord) -> std::borrow::Cow<'_, str> {
663 match &record.value {
664 HandleValue::Text(text) => std::borrow::Cow::Borrowed(text),
665 HandleValue::Json(value) => {
666 std::borrow::Cow::Owned(serde_json::to_string_pretty(value).unwrap_or_default())
667 }
668 }
669 }
670
671 pub(crate) fn query_jsonpath(root: &Value, path: &str) -> Result<Vec<Value>, String> {
672 if !path.starts_with('$') {
673 return Err("jsonpath must start with `$`".to_string());
674 }
675 let mut idx = 1usize;
676 let bytes = path.as_bytes();
677 let mut current = vec![root];
678 while idx < bytes.len() {
679 match bytes[idx] {
680 b'.' => {
681 idx += 1;
682 if idx < bytes.len() && bytes[idx] == b'.' {
683 return Err("recursive descent (`..`) is not supported".to_string());
684 }
685 let start = idx;
686 while idx < bytes.len()
687 && (bytes[idx].is_ascii_alphanumeric() || bytes[idx] == b'_')
688 {
689 idx += 1;
690 }
691 if start == idx {
692 return Err("expected field name after `.`".to_string());
693 }
694 let field = &path[start..idx];
695 current = current
696 .into_iter()
697 .filter_map(|value| value.get(field))
698 .collect();
699 }
700 b'[' => {
701 let Some(close_rel) = path[idx + 1..].find(']') else {
702 return Err("unterminated `[` segment".to_string());
703 };
704 let close = idx + 1 + close_rel;
705 let token = path[idx + 1..close].trim();
706 idx = close + 1;
707 current = apply_bracket_token(current, token)?;
708 }
709 other => {
710 return Err(format!(
711 "unexpected character `{}` in jsonpath",
712 other as char
713 ));
714 }
715 }
716 }
717 Ok(current.into_iter().cloned().collect())
718 }
719
720 fn apply_bracket_token<'a>(values: Vec<&'a Value>, token: &str) -> Result<Vec<&'a Value>, String> {
721 if token == "*" {
722 let mut out = Vec::new();
723 for value in values {
724 match value {
725 Value::Array(items) => out.extend(items),
726 Value::Object(map) => out.extend(map.values()),
727 _ => {}
728 }
729 }
730 return Ok(out);
731 }
732
733 if let Some(field) = quoted_field(token) {
734 return Ok(values
735 .into_iter()
736 .filter_map(|value| value.get(field))
737 .collect());
738 }
739
740 let index = token
741 .parse::<usize>()
742 .map_err(|_| format!("unsupported bracket token `{token}`"))?;
743 Ok(values
744 .into_iter()
745 .filter_map(|value| value.as_array().and_then(|items| items.get(index)))
746 .collect())
747 }
748
749 fn quoted_field(token: &str) -> Option<&str> {
750 if token.len() < 2 {
751 return None;
752 }
753 let bytes = token.as_bytes();
754 let quote = bytes[0];
755 if !matches!(quote, b'\'' | b'"') || bytes[token.len() - 1] != quote {
756 return None;
757 }
758 Some(&token[1..token.len() - 1])
759 }
760
761 fn char_slice(text: &str, start: usize, end: usize) -> String {
762 text.chars()
763 .skip(start)
764 .take(end.saturating_sub(start))
765 .collect()
766 }
767
768 fn truncate_chars(text: &str, max_chars: usize) -> String {
769 let mut out = String::new();
770 for (idx, ch) in text.chars().enumerate() {
771 if idx == max_chars {
772 break;
773 }
774 out.push(ch);
775 }
776 out
777 }
778
779 #[allow(dead_code)] // Used when producer tools register handle payloads.
780 fn sha256_hex(bytes: &[u8]) -> String {
781 crate::hashing::sha256_hex(bytes)
782 }
783
784 fn json_type(value: &Value) -> &'static str {
785 match value {
786 Value::Null => "null",
787 Value::Bool(_) => "bool",
788 Value::Number(_) => "number",
789 Value::String(_) => "string",
790 Value::Array(_) => "array",
791 Value::Object(_) => "object",
792 }
793 }
794
795 #[cfg(test)]
796 mod tests {
797 use super::*;
798 use serde_json::json;
799
800 fn ctx() -> ToolContext {
801 ToolContext::new(".")
802 }
803
804 #[tokio::test]
805 async fn handle_read_slices_text_by_chars() {
806 let ctx = ctx();
807 let handle = {
808 let mut store = ctx.runtime.handle_store.lock().await;
809 store.insert_text("rlm:test", "matches", "abcdef")
810 };
811
812 let result = HandleReadTool
813 .execute(
814 json!({"handle": handle, "slice": {"start": 1, "end": 4}}),
815 &ctx,
816 )
817 .await
818 .expect("execute");
819 let body: Value = serde_json::from_str(&result.content).expect("json");
820 assert_eq!(body["content"], "bcd");
821 assert_eq!(body["truncated"], false);
822 }
823
824 #[tokio::test]
825 async fn handle_read_ranges_text_by_one_based_lines() {
826 let ctx = ctx();
827 let handle = {
828 let mut store = ctx.runtime.handle_store.lock().await;
829 store.insert_text("agent:test", "transcript", "one\ntwo\nthree\nfour")
830 };
831
832 let result = HandleReadTool
833 .execute(
834 json!({"handle": handle, "range": {"start": 2, "end": 3}}),
835 &ctx,
836 )
837 .await
838 .expect("execute");
839 let body: Value = serde_json::from_str(&result.content).expect("json");
840 assert_eq!(body["content"], "two\nthree");
841 assert_eq!(body["meta"]["shown_start"], 2);
842 assert_eq!(body["meta"]["shown_end"], 3);
843 }
844
845 #[tokio::test]
846 async fn handle_read_counts_json_collections() {
847 let ctx = ctx();
848 let handle = {
849 let mut store = ctx.runtime.handle_store.lock().await;
850 store.insert_json("rlm:test", "items", json!([{"a": 1}, {"a": 2}]))
851 };
852
853 let result = HandleReadTool
854 .execute(json!({"handle": handle, "count": true}), &ctx)
855 .await
856 .expect("execute");
857 let body: Value = serde_json::from_str(&result.content).expect("json");
858 assert_eq!(body["json_type"], "array");
859 assert_eq!(body["length"], 2);
860 }
861
862 #[tokio::test]
863 async fn handle_read_introspects_object_handle_with_examples() {
864 let ctx = ctx();
865 let handle = {
866 let mut store = ctx.runtime.handle_store.lock().await;
867 store.insert_json("rlm:test", "items", json!({"items": [{"a": 1}]}))
868 };
869
870 let result = HandleReadTool
871 .execute(json!({"handle": handle, "introspect": true}), &ctx)
872 .await
873 .expect("execute");
874 let body: Value = serde_json::from_str(&result.content).expect("json");
875 assert_eq!(body["projection"], "introspect");
876 assert_eq!(body["handle"]["kind"], "var_handle");
877 assert!(
878 body["projections"]
879 .as_array()
880 .expect("projection examples")
881 .iter()
882 .any(|entry| entry["name"] == "jsonpath"),
883 "json handles should advertise jsonpath examples"
884 );
885 }
886
887 #[tokio::test]
888 async fn handle_read_projects_jsonpath_subset() {
889 let ctx = ctx();
890 let handle = {
891 let mut store = ctx.runtime.handle_store.lock().await;
892 store.insert_json(
893 "rlm:test",
894 "items",
895 json!({"items": [{"name": "a"}, {"name": "b"}]}),
896 )
897 };
898
899 let result = HandleReadTool
900 .execute(
901 json!({"handle": handle, "jsonpath": "$.items[*].name"}),
902 &ctx,
903 )
904 .await
905 .expect("execute");
906 let body: Value = serde_json::from_str(&result.content).expect("json");
907 assert_eq!(body["matches"], json!(["a", "b"]));
908 assert_eq!(body["count"], 2);
909 }
910
911 #[tokio::test]
912 async fn handle_read_rejects_unbounded_projection_requests() {
913 let ctx = ctx();
914 let handle = {
915 let mut store = ctx.runtime.handle_store.lock().await;
916 store.insert_text("rlm:test", "body", "abc")
917 };
918
919 let err = HandleReadTool
920 .execute(json!({"handle": handle}), &ctx)
921 .await
922 .expect_err("projection required");
923 let message = err.to_string();
924 assert!(message.contains("exactly one"));
925 assert!(message.contains("slice"));
926 assert!(message.contains("introspect"));
927 }
928
929 #[tokio::test]
930 async fn handle_read_points_artifact_refs_to_tool_result_retrieval() {
931 let ctx = ctx();
932 let err = HandleReadTool
933 .execute(json!({"handle": "art_call_abc123", "count": true}), &ctx)
934 .await
935 .expect_err("artifact refs are not var handles");
936 let message = err.to_string();
937 assert!(message.contains("retrieve_tool_result"));
938 assert!(message.contains("artifact/tool-result ref"));
939 }
940 }
941
941 lines RUST