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