返回 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 /// `handle_read` is deferred on the default catalog, so model-facing text
242 /// that points at it also teaches how to activate it (#6747).
243 pub(crate) const HANDLE_READ_ACTIVATION_HINT: &str =
244 "if `handle_read` is not in your tool list, load it with `tool_search` first";
245
246 pub struct HandleReadTool;
247
248 #[async_trait]
249 impl ToolSpec for HandleReadTool {
250 fn name(&self) -> &'static str {
251 "handle_read"
252 }
253
254 fn description(&self) -> &'static str {
255 "Read a bounded projection from a var_handle returned by tools such \
256 as RLM sessions or sub-agents. This does not read artifact ids \
257 (`art_...`), tool-call ids (`call_...`), SHA refs, or files; use \
258 retrieve_tool_result for spilled tool results/artifacts and \
259 `read` (path=...) for workspace files. Provide \
260 exactly one projection: `slice` for char/line slices, `range` for \
261 one-based line ranges, `count` for metadata counts, or `jsonpath` \
262 for a small JSON-path projection. This retrieves from the handle's \
263 backing environment instead of asking the parent transcript to hold \
264 the full payload."
265 }
266
267 fn input_schema(&self) -> Value {
268 json!({
269 "type": "object",
270 "required": ["handle"],
271 "properties": {
272 "handle": {
273 "description": "A var_handle object, or a compact `session_id/name` string. Not an `art_...`, `call_...`, SHA, or file path ref.",
274 "oneOf": [
275 {
276 "type": "object",
277 "required": ["kind", "session_id", "name"],
278 "properties": {
279 "kind": { "type": "string", "const": "var_handle" },
280 "session_id": { "type": "string" },
281 "name": { "type": "string" },
282 "type": { "type": "string" },
283 "length": { "type": "integer" },
284 "repr_preview": { "type": "string" },
285 "sha256": { "type": "string" }
286 }
287 },
288 { "type": "string" }
289 ]
290 },
291 "slice": {
292 "type": "object",
293 "description": "Zero-based half-open slice over chars or lines.",
294 "properties": {
295 "start": { "type": "integer", "minimum": 0 },
296 "end": { "type": "integer", "minimum": 0 },
297 "unit": { "type": "string", "enum": ["chars", "lines"], "default": "chars" }
298 }
299 },
300 "range": {
301 "type": "object",
302 "description": "One-based inclusive line range.",
303 "required": ["start", "end"],
304 "properties": {
305 "start": { "type": "integer", "minimum": 1 },
306 "end": { "type": "integer", "minimum": 1 }
307 }
308 },
309 "count": {
310 "type": "boolean",
311 "description": "Return counts for the handle payload."
312 },
313 "jsonpath": {
314 "type": "string",
315 "description": "Small JSONPath subset: $, .field, [index], [*], and ['field']."
316 },
317 "introspect": {
318 "type": "boolean",
319 "description": "Return supported projections, size hints, and copy-pasteable examples for this handle."
320 },
321 "max_chars": {
322 "type": "integer",
323 "description": "Maximum characters to return in this projection. Defaults to 12000; hard-capped at 50000."
324 }
325 }
326 })
327 }
328
329 fn capabilities(&self) -> Vec<ToolCapability> {
330 vec![ToolCapability::ReadOnly]
331 }
332
333 fn approval_requirement(&self) -> ApprovalRequirement {
334 ApprovalRequirement::Auto
335 }
336
337 fn supports_parallel(&self) -> bool {
338 true
339 }
340
341 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
342 let handle = parse_handle(
343 input
344 .get("handle")
345 .ok_or_else(|| ToolError::missing_field("handle"))?,
346 )?;
347 let projection = parse_projection(&input)?;
348 let max_chars = input
349 .get("max_chars")
350 .and_then(Value::as_u64)
351 .map(|n| (n as usize).min(HARD_MAX_CHARS))
352 .unwrap_or(DEFAULT_MAX_CHARS);
353
354 let store = context.runtime.handle_store.lock().await;
355 let record = store.get(&handle).ok_or_else(|| {
356 ToolError::invalid_input(format!(
357 "handle_read: no payload found for handle {}/{}",
358 handle.session_id, handle.name
359 ))
360 })?;
361 if !handle.sha256.is_empty() && handle.sha256 != record.handle.sha256 {
362 return Err(ToolError::invalid_input(
363 "handle_read: handle sha256 does not match stored payload",
364 ));
365 }
366
367 let output = match projection {
368 Projection::Count => count_projection(record),
369 Projection::Slice { start, end, unit } => {
370 slice_projection(record, start, end, unit, max_chars)
371 }
372 Projection::Range { start, end } => {
373 line_range_projection(record, start, end, max_chars)
374 }
375 Projection::JsonPath(path) => jsonpath_projection(record, &path, max_chars)?,
376 Projection::Introspect => introspect_projection(record),
377 };
378
379 ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string()))
380 }
381 }
382
383 #[derive(Debug, Clone, Copy)]
384 enum SliceUnit {
385 Chars,
386 Lines,
387 }
388
389 enum Projection {
390 Count,
391 Slice {
392 start: usize,
393 end: Option<usize>,
394 unit: SliceUnit,
395 },
396 Range {
397 start: usize,
398 end: usize,
399 },
400 JsonPath(String),
401 Introspect,
402 }
403
404 fn parse_handle(value: &Value) -> Result<VarHandle, ToolError> {
405 if let Some(raw) = value.as_str() {
406 if looks_like_tool_result_ref(raw) {
407 return Err(ToolError::invalid_input(
408 "handle_read only accepts var_handle objects or `session_id/name` strings. \
409 This looks like an artifact/tool-result ref; use `retrieve_tool_result` instead.",
410 ));
411 }
412 let Some((session_id, name)) = raw.rsplit_once('/') else {
413 return Err(ToolError::invalid_input(
414 "handle_read: string handles must use `session_id/name`. \
415 For `art_...`, `call_...`, SHA, or file refs, use `retrieve_tool_result`.",
416 ));
417 };
418 return Ok(VarHandle {
419 kind: "var_handle".to_string(),
420 session_id: session_id.to_string(),
421 name: name.to_string(),
422 type_name: String::new(),
423 length: 0,
424 repr_preview: String::new(),
425 sha256: String::new(),
426 });
427 }
428
429 let handle: VarHandle = serde_json::from_value(value.clone()).map_err(|e| {
430 ToolError::invalid_input(format!("handle_read: invalid var_handle object: {e}"))
431 })?;
432 if handle.kind != "var_handle" {
433 return Err(ToolError::invalid_input(
434 "handle_read: handle.kind must be `var_handle`",
435 ));
436 }
437 if handle.session_id.trim().is_empty() || handle.name.trim().is_empty() {
438 return Err(ToolError::invalid_input(
439 "handle_read: handle.session_id and handle.name must be non-empty",
440 ));
441 }
442 Ok(handle)
443 }
444
445 fn looks_like_tool_result_ref(raw: &str) -> bool {
446 let trimmed = raw.trim();
447 let sha_candidate = trimmed
448 .strip_prefix("sha:")
449 .or_else(|| trimmed.strip_prefix("sha_"))
450 .unwrap_or(trimmed);
451 trimmed.starts_with("art_")
452 || trimmed.starts_with("call_")
453 || trimmed.starts_with("tool_result:")
454 || trimmed.ends_with(".txt")
455 || crate::tools::truncate::is_valid_sha256(&sha_candidate.to_ascii_lowercase())
456 }
457
458 fn parse_projection(input: &Value) -> Result<Projection, ToolError> {
459 let mut count = 0usize;
460 count += usize::from(input.get("slice").is_some());
461 count += usize::from(input.get("range").is_some());
462 count += usize::from(input.get("count").and_then(Value::as_bool).unwrap_or(false));
463 count += usize::from(input.get("jsonpath").is_some());
464 count += usize::from(
465 input
466 .get("introspect")
467 .and_then(Value::as_bool)
468 .unwrap_or(false),
469 );
470 if count != 1 {
471 return Err(ToolError::invalid_input(projection_usage_hint()));
472 }
473
474 if input
475 .get("introspect")
476 .and_then(Value::as_bool)
477 .unwrap_or(false)
478 {
479 return Ok(Projection::Introspect);
480 }
481 if input.get("count").and_then(Value::as_bool).unwrap_or(false) {
482 return Ok(Projection::Count);
483 }
484 if let Some(path) = input.get("jsonpath") {
485 let path = path
486 .as_str()
487 .ok_or_else(|| ToolError::invalid_input("handle_read: jsonpath must be a string"))?
488 .trim();
489 if path.is_empty() {
490 return Err(ToolError::invalid_input(
491 "handle_read: jsonpath must not be empty",
492 ));
493 }
494 return Ok(Projection::JsonPath(path.to_string()));
495 }
496 if let Some(slice) = input.get("slice") {
497 let start = slice.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
498 let end = slice.get("end").and_then(Value::as_u64).map(|n| n as usize);
499 if let Some(end) = end
500 && end < start
501 {
502 return Err(ToolError::invalid_input(
503 "handle_read: slice.end must be greater than or equal to slice.start",
504 ));
505 }
506 let unit = match slice.get("unit").and_then(Value::as_str).unwrap_or("chars") {
507 "chars" => SliceUnit::Chars,
508 "lines" => SliceUnit::Lines,
509 other => {
510 return Err(ToolError::invalid_input(format!(
511 "handle_read: unsupported slice.unit `{other}`"
512 )));
513 }
514 };
515 return Ok(Projection::Slice { start, end, unit });
516 }
517 let range = input
518 .get("range")
519 .ok_or_else(|| ToolError::invalid_input("handle_read: missing projection"))?;
520 let start = range
521 .get("start")
522 .and_then(Value::as_u64)
523 .ok_or_else(|| ToolError::missing_field("range.start"))? as usize;
524 let end = range
525 .get("end")
526 .and_then(Value::as_u64)
527 .ok_or_else(|| ToolError::missing_field("range.end"))? as usize;
528 if start == 0 || end == 0 || end < start {
529 return Err(ToolError::invalid_input(
530 "handle_read: range is one-based inclusive and end must be >= start",
531 ));
532 }
533 Ok(Projection::Range { start, end })
534 }
535
536 fn projection_usage_hint() -> String {
537 "handle_read: provide exactly one projection: `slice`, `range`, `count: true`, `jsonpath`, or `introspect: true`. \
538 Examples: {\"handle\":{\"kind\":\"var_handle\",\"session_id\":\"rlm:abc\",\"name\":\"final_1\"},\"slice\":{\"start\":0,\"end\":500}}; \
539 {\"handle\":\"rlm:abc/final_1\",\"count\":true}; \
540 {\"handle\":\"rlm:abc/final_1\",\"introspect\":true}."
541 .to_string()
542 }
543
544 fn count_projection(record: &HandleRecord) -> Value {
545 match &record.value {
546 HandleValue::Text(text) => json!({
547 "handle": record.handle,
548 "projection": "count",
549 "chars": text.chars().count(),
550 "lines": text.lines().count(),
551 "bytes": text.len(),
552 }),
553 HandleValue::Json(value) => {
554 let bytes = {
555 let mut cw = crate::utils::CountingWriter::new();
556 let _ = serde_json::to_writer(&mut cw, value);
557 cw.count()
558 };
559 json!({
560 "handle": record.handle,
561 "projection": "count",
562 "json_type": json_type(value),
563 "length": record.handle.length,
564 "bytes": bytes,
565 })
566 }
567 }
568 }
569
570 fn introspect_projection(record: &HandleRecord) -> Value {
571 let string_handle = format!("{}/{}", record.handle.session_id, record.handle.name);
572 let object_handle = json!(record.handle.clone());
573 let mut projections = vec![
574 json!({"name": "count", "example": {"handle": string_handle, "count": true}}),
575 json!({"name": "slice_chars", "example": {"handle": object_handle.clone(), "slice": {"start": 0, "end": 500}}}),
576 json!({"name": "range_lines", "example": {"handle": object_handle.clone(), "range": {"start": 1, "end": 20}}}),
577 ];
578 if matches!(record.value, HandleValue::Json(_)) {
579 projections.push(
580 json!({"name": "jsonpath", "example": {"handle": object_handle, "jsonpath": "$"}}),
581 );
582 }
583
584 json!({
585 "handle": record.handle,
586 "projection": "introspect",
587 "value_type": match &record.value {
588 HandleValue::Text(_) => "text",
589 HandleValue::Json(value) => json_type(value),
590 },
591 "length": record.handle.length,
592 "repr_preview": record.handle.repr_preview,
593 "projections": projections,
594 })
595 }
596
597 fn slice_projection(
598 record: &HandleRecord,
599 start: usize,
600 end: Option<usize>,
601 unit: SliceUnit,
602 max_chars: usize,
603 ) -> Value {
604 let text = record_text(record);
605 match unit {
606 SliceUnit::Chars => {
607 let total = text.chars().count();
608 let end = end.unwrap_or(total).min(total);
609 let raw = char_slice(&text, start.min(total), end);
610 bounded_text_projection(
611 record,
612 "slice",
613 raw,
614 max_chars,
615 json!({
616 "unit": "chars",
617 "start": start.min(total),
618 "end": end,
619 "total_chars": total,
620 }),
621 )
622 }
623 SliceUnit::Lines => {
624 let lines: Vec<&str> = text.lines().collect();
625 let total = lines.len();
626 let end = end.unwrap_or(total).min(total);
627 let raw = if start >= end {
628 String::new()
629 } else {
630 lines[start.min(total)..end].join("\n")
631 };
632 bounded_text_projection(
633 record,
634 "slice",
635 raw,
636 max_chars,
637 json!({
638 "unit": "lines",
639 "start": start.min(total),
640 "end": end,
641 "total_lines": total,
642 }),
643 )
644 }
645 }
646 }
647
648 fn line_range_projection(
649 record: &HandleRecord,
650 start: usize,
651 end: usize,
652 max_chars: usize,
653 ) -> Value {
654 let text = record_text(record);
655 let lines: Vec<&str> = text.lines().collect();
656 let total = lines.len();
657 let zero_start = start.saturating_sub(1).min(total);
658 let zero_end = end.min(total);
659 let raw = if zero_start >= zero_end {
660 String::new()
661 } else {
662 lines[zero_start..zero_end].join("\n")
663 };
664 bounded_text_projection(
665 record,
666 "range",
667 raw,
668 max_chars,
669 json!({
670 "start": start,
671 "end": end,
672 "shown_start": zero_start + 1,
673 "shown_end": zero_end,
674 "total_lines": total,
675 }),
676 )
677 }
678
679 fn jsonpath_projection(
680 record: &HandleRecord,
681 path: &str,
682 max_chars: usize,
683 ) -> Result<Value, ToolError> {
684 let HandleValue::Json(value) = &record.value else {
685 return Err(ToolError::invalid_input(
686 "handle_read: jsonpath projection requires a JSON handle",
687 ));
688 };
689 let matches = query_jsonpath(value, path)
690 .map_err(|e| ToolError::invalid_input(format!("handle_read: {e}")))?;
691 let mut payload = json!({
692 "handle": record.handle,
693 "projection": "jsonpath",
694 "jsonpath": path,
695 "count": matches.len(),
696 "matches": matches,
697 "truncated": false,
698 });
699 let rendered = serde_json::to_string(&payload).unwrap_or_default();
700 if rendered.chars().count() > max_chars {
701 payload["matches"] = json!([]);
702 payload["preview"] = json!(truncate_chars(&rendered, max_chars));
703 payload["truncated"] = json!(true);
704 }
705 Ok(payload)
706 }
707
708 fn bounded_text_projection(
709 record: &HandleRecord,
710 projection: &str,
711 raw: String,
712 max_chars: usize,
713 extra: Value,
714 ) -> Value {
715 let raw_chars = raw.chars().count();
716 let content = truncate_chars(&raw, max_chars);
717 let shown_chars = content.chars().count();
718 json!({
719 "handle": record.handle,
720 "projection": projection,
721 "content": content,
722 "truncated": shown_chars < raw_chars,
723 "shown_chars": shown_chars,
724 "omitted_chars": raw_chars.saturating_sub(shown_chars),
725 "meta": extra,
726 })
727 }
728
729 fn record_text(record: &HandleRecord) -> std::borrow::Cow<'_, str> {
730 match &record.value {
731 HandleValue::Text(text) => std::borrow::Cow::Borrowed(text),
732 HandleValue::Json(value) => {
733 std::borrow::Cow::Owned(serde_json::to_string_pretty(value).unwrap_or_default())
734 }
735 }
736 }
737
738 pub(crate) fn query_jsonpath(root: &Value, path: &str) -> Result<Vec<Value>, String> {
739 if !path.starts_with('$') {
740 return Err("jsonpath must start with `$`".to_string());
741 }
742 let mut idx = 1usize;
743 let bytes = path.as_bytes();
744 let mut current = vec![root];
745 while idx < bytes.len() {
746 match bytes[idx] {
747 b'.' => {
748 idx += 1;
749 if idx < bytes.len() && bytes[idx] == b'.' {
750 return Err("recursive descent (`..`) is not supported".to_string());
751 }
752 let start = idx;
753 while idx < bytes.len()
754 && (bytes[idx].is_ascii_alphanumeric() || bytes[idx] == b'_')
755 {
756 idx += 1;
757 }
758 if start == idx {
759 return Err("expected field name after `.`".to_string());
760 }
761 let field = &path[start..idx];
762 current = current
763 .into_iter()
764 .filter_map(|value| value.get(field))
765 .collect();
766 }
767 b'[' => {
768 let Some(close_rel) = path[idx + 1..].find(']') else {
769 return Err("unterminated `[` segment".to_string());
770 };
771 let close = idx + 1 + close_rel;
772 let token = path[idx + 1..close].trim();
773 idx = close + 1;
774 current = apply_bracket_token(current, token)?;
775 }
776 other => {
777 return Err(format!(
778 "unexpected character `{}` in jsonpath",
779 other as char
780 ));
781 }
782 }
783 }
784 Ok(current.into_iter().cloned().collect())
785 }
786
787 fn apply_bracket_token<'a>(values: Vec<&'a Value>, token: &str) -> Result<Vec<&'a Value>, String> {
788 if token == "*" {
789 let mut out = Vec::new();
790 for value in values {
791 match value {
792 Value::Array(items) => out.extend(items),
793 Value::Object(map) => out.extend(map.values()),
794 _ => {}
795 }
796 }
797 return Ok(out);
798 }
799
800 if let Some(field) = quoted_field(token) {
801 return Ok(values
802 .into_iter()
803 .filter_map(|value| value.get(field))
804 .collect());
805 }
806
807 let index = token
808 .parse::<usize>()
809 .map_err(|_| format!("unsupported bracket token `{token}`"))?;
810 Ok(values
811 .into_iter()
812 .filter_map(|value| value.as_array().and_then(|items| items.get(index)))
813 .collect())
814 }
815
816 fn quoted_field(token: &str) -> Option<&str> {
817 if token.len() < 2 {
818 return None;
819 }
820 let bytes = token.as_bytes();
821 let quote = bytes[0];
822 if !matches!(quote, b'\'' | b'"') || bytes[token.len() - 1] != quote {
823 return None;
824 }
825 Some(&token[1..token.len() - 1])
826 }
827
828 fn char_slice(text: &str, start: usize, end: usize) -> String {
829 text.chars()
830 .skip(start)
831 .take(end.saturating_sub(start))
832 .collect()
833 }
834
835 fn truncate_chars(text: &str, max_chars: usize) -> String {
836 let mut out = String::new();
837 for (idx, ch) in text.chars().enumerate() {
838 if idx == max_chars {
839 break;
840 }
841 out.push(ch);
842 }
843 out
844 }
845
846 fn sha256_hex(bytes: &[u8]) -> String {
847 crate::hashing::sha256_hex(bytes)
848 }
849
850 fn json_type(value: &Value) -> &'static str {
851 match value {
852 Value::Null => "null",
853 Value::Bool(_) => "bool",
854 Value::Number(_) => "number",
855 Value::String(_) => "string",
856 Value::Array(_) => "array",
857 Value::Object(_) => "object",
858 }
859 }
860
861 #[cfg(test)]
862 mod tests {
863 use super::*;
864 use serde_json::json;
865
866 fn ctx() -> ToolContext {
867 ToolContext::new(".")
868 }
869
870 #[tokio::test]
871 async fn handle_read_slices_text_by_chars() {
872 let ctx = ctx();
873 let handle = {
874 let mut store = ctx.runtime.handle_store.lock().await;
875 store.insert_text("rlm:test", "matches", "abcdef")
876 };
877
878 let result = HandleReadTool
879 .execute(
880 json!({"handle": handle, "slice": {"start": 1, "end": 4}}),
881 &ctx,
882 )
883 .await
884 .expect("execute");
885 let body: Value = serde_json::from_str(&result.content).expect("json");
886 assert_eq!(body["content"], "bcd");
887 assert_eq!(body["truncated"], false);
888 }
889
890 #[tokio::test]
891 async fn handle_read_ranges_text_by_one_based_lines() {
892 let ctx = ctx();
893 let handle = {
894 let mut store = ctx.runtime.handle_store.lock().await;
895 store.insert_text("agent:test", "transcript", "one\ntwo\nthree\nfour")
896 };
897
898 let result = HandleReadTool
899 .execute(
900 json!({"handle": handle, "range": {"start": 2, "end": 3}}),
901 &ctx,
902 )
903 .await
904 .expect("execute");
905 let body: Value = serde_json::from_str(&result.content).expect("json");
906 assert_eq!(body["content"], "two\nthree");
907 assert_eq!(body["meta"]["shown_start"], 2);
908 assert_eq!(body["meta"]["shown_end"], 3);
909 }
910
911 #[tokio::test]
912 async fn handle_read_counts_json_collections() {
913 let ctx = ctx();
914 let handle = {
915 let mut store = ctx.runtime.handle_store.lock().await;
916 store.insert_json("rlm:test", "items", json!([{"a": 1}, {"a": 2}]))
917 };
918
919 let result = HandleReadTool
920 .execute(json!({"handle": handle, "count": true}), &ctx)
921 .await
922 .expect("execute");
923 let body: Value = serde_json::from_str(&result.content).expect("json");
924 assert_eq!(body["json_type"], "array");
925 assert_eq!(body["length"], 2);
926 }
927
928 #[tokio::test]
929 async fn handle_read_introspects_object_handle_with_examples() {
930 let ctx = ctx();
931 let handle = {
932 let mut store = ctx.runtime.handle_store.lock().await;
933 store.insert_json("rlm:test", "items", json!({"items": [{"a": 1}]}))
934 };
935
936 let result = HandleReadTool
937 .execute(json!({"handle": handle, "introspect": true}), &ctx)
938 .await
939 .expect("execute");
940 let body: Value = serde_json::from_str(&result.content).expect("json");
941 assert_eq!(body["projection"], "introspect");
942 assert_eq!(body["handle"]["kind"], "var_handle");
943 assert!(
944 body["projections"]
945 .as_array()
946 .expect("projection examples")
947 .iter()
948 .any(|entry| entry["name"] == "jsonpath"),
949 "json handles should advertise jsonpath examples"
950 );
951 }
952
953 #[tokio::test]
954 async fn handle_read_projects_jsonpath_subset() {
955 let ctx = ctx();
956 let handle = {
957 let mut store = ctx.runtime.handle_store.lock().await;
958 store.insert_json(
959 "rlm:test",
960 "items",
961 json!({"items": [{"name": "a"}, {"name": "b"}]}),
962 )
963 };
964
965 let result = HandleReadTool
966 .execute(
967 json!({"handle": handle, "jsonpath": "$.items[*].name"}),
968 &ctx,
969 )
970 .await
971 .expect("execute");
972 let body: Value = serde_json::from_str(&result.content).expect("json");
973 assert_eq!(body["matches"], json!(["a", "b"]));
974 assert_eq!(body["count"], 2);
975 }
976
977 #[tokio::test]
978 async fn handle_read_rejects_unbounded_projection_requests() {
979 let ctx = ctx();
980 let handle = {
981 let mut store = ctx.runtime.handle_store.lock().await;
982 store.insert_text("rlm:test", "body", "abc")
983 };
984
985 let err = HandleReadTool
986 .execute(json!({"handle": handle}), &ctx)
987 .await
988 .expect_err("projection required");
989 let message = err.to_string();
990 assert!(message.contains("exactly one"));
991 assert!(message.contains("slice"));
992 assert!(message.contains("introspect"));
993 }
994
995 #[tokio::test]
996 async fn handle_read_points_artifact_refs_to_tool_result_retrieval() {
997 let ctx = ctx();
998 let err = HandleReadTool
999 .execute(json!({"handle": "art_call_abc123", "count": true}), &ctx)
1000 .await
1001 .expect_err("artifact refs are not var handles");
1002 let message = err.to_string();
1003 assert!(message.contains("retrieve_tool_result"));
1004 assert!(message.contains("artifact/tool-result ref"));
1005 }
1006
1007 // === #5472 findings 4-5: the store is bounded across all sessions ===
1008
1009 #[test]
1010 fn handle_store_evicts_oldest_records_past_its_byte_budget() {
1011 let mut store = HandleStore::default();
1012 // 96 x 1 MiB across distinct sessions — the RLM shape, which had no
1013 // eviction path at all because nothing ever calls `evict_session` for it.
1014 let payload = "z".repeat(1024 * 1024);
1015 for index in 0..96 {
1016 let _ = store.insert_text(format!("rlm-session-{index}"), "value", payload.clone());
1017 }
1018 assert!(
1019 store.retained_bytes() <= HANDLE_STORE_MAX_BYTES,
1020 "store held {} bytes, over the {HANDLE_STORE_MAX_BYTES} budget",
1021 store.retained_bytes()
1022 );
1023 let newest = VarHandle {
1024 kind: "var_handle".to_string(),
1025 session_id: "rlm-session-95".to_string(),
1026 name: "value".to_string(),
1027 type_name: "str".to_string(),
1028 length: payload.chars().count(),
1029 repr_preview: String::new(),
1030 sha256: String::new(),
1031 };
1032 assert!(
1033 store.get(&newest).is_some(),
1034 "the most recent handle must survive — it is the one still referenced"
1035 );
1036 }
1037
1038 #[test]
1039 fn evicting_a_session_returns_its_bytes_to_the_budget() {
1040 let mut store = HandleStore::default();
1041 let _ = store.insert_text("session-a", "value", "a".repeat(4096));
1042 let _ = store.insert_text("session-b", "value", "b".repeat(4096));
1043 let before = store.retained_bytes();
1044 assert_eq!(before, 8192);
1045 store.evict_session("session-a");
1046 assert_eq!(
1047 store.retained_bytes(),
1048 4096,
1049 "per-session eviction must not leave phantom bytes on the budget"
1050 );
1051 }
1052 }
1053
1053 lines RUST