- 1
//! `recall`: reopen a past turn, presentation, or evidence result by id. - 2
//! - 3
//! Part of docs/design/68-context-engine.md §3/§7/§10's evidence store: past - 4
//! turns are digested (or reduced to a card line) in the request, and this - 5
//! tool is how the model reverses that — full turn record, canonical - 6
//! presentation payload, or full tool-result content, optionally sliced by - 7
//! line range. - 8
//! - 9
//! `RecallTool` only carries the definition (name/schema/description): it - 10
//! has no session access (`ToolContext` carries none — AGENTS.md invariant - 11
//! 14's worker/broker boundary), so the agent loop intercepts `recall` calls - 12
//! by name before dispatch and answers them from the session directly, - 13
//! exactly as `emit_*_card` results are rewritten after `execute_batch`, but - 14
//! earlier — `recall` is never sent to a worker. `execute()` here exists - 15
//! only as a defensive fallback and must never be reachable in production. - 16
//! [`parse_recall_args`] and [`apply_range`] are the pure logic the loop - 17
//! reuses to validate arguments and slice evidence content. - 18
- 19
use async_trait::async_trait; - 20
use serde_json::{Value, json}; - 21
- 22
use crate::{Tool, ToolContext, ToolOutput}; - 23
- 24
/// One parsed, validated `recall` call. Exactly one of the three request - 25
/// shapes is ever produced by [`parse_recall_args`]. - 26
#[derive(Debug, Clone, PartialEq)] - 27
pub enum RecallRequest { - 28
/// `{ turn }`: the 1-based turn number (`TurnIndex` order). - 29
Turn(u64), - 30
/// `{ presentation }`: a `Presentation` ledger-entry id. - 31
Presentation(String), - 32
/// `{ id, range? }`: an evidence id (tool_use_id), optionally sliced to - 33
/// a 1-based inclusive line range. - 34
Id { - 35
id: String, - 36
range: Option<(u64, u64)>, - 37
}, - 38
} - 39
- 40
/// Validates a `recall` call's arguments: exactly one of `turn`, - 41
/// `presentation`, or `id` must be present; `range` is only meaningful with - 42
/// `id` but is not rejected when present alongside another field (the - 43
/// resolver simply ignores it) — the one-of check is what actually matters. - 44
pub fn parse_recall_args(args: &Value) -> Result<RecallRequest, String> { - 45
let turn = args.get("turn").and_then(Value::as_u64); - 46
let presentation = args - 47
.get("presentation") - 48
.and_then(Value::as_str) - 49
.map(str::to_string); - 50
let id = args.get("id").and_then(Value::as_str).map(str::to_string); - 51
match (turn, presentation, id) { - 52
(Some(turn), None, None) => Ok(RecallRequest::Turn(turn)), - 53
(None, Some(presentation), None) => Ok(RecallRequest::Presentation(presentation)), - 54
(None, None, Some(id)) => { - 55
let range = match args.get("range") { - 56
None | Some(Value::Null) => None, - 57
Some(range) => { - 58
let start = range.get("start").and_then(Value::as_u64); - 59
let end = range.get("end").and_then(Value::as_u64); - 60
match (start, end) { - 61
(Some(start), Some(end)) => Some((start, end)), - 62
_ => { - 63
return Err("'range' requires integer 'start' and 'end'".to_string()); - 64
} - 65
} - 66
} - 67
}; - 68
Ok(RecallRequest::Id { id, range }) - 69
} - 70
_ => Err("exactly one of 'turn', 'presentation', or 'id' is required".to_string()), - 71
} - 72
} - 73
- 74
/// Applies an optional 1-based inclusive line range to `content`. Pure: no - 75
/// I/O, no session access. Out-of-range bounds clamp rather than error, so a - 76
/// model guessing at a range still gets whatever exists. - 77
pub fn apply_range(content: &str, range: Option<(u64, u64)>) -> String { - 78
let Some((start, end)) = range else { - 79
return content.to_string(); - 80
}; - 81
let lines: Vec<&str> = content.lines().collect(); - 82
if lines.is_empty() { - 83
return String::new(); - 84
} - 85
let start_idx = start.max(1) as usize - 1; - 86
let end_idx = end.max(start) as usize; - 87
if start_idx >= lines.len() { - 88
return String::new(); - 89
} - 90
lines[start_idx..end_idx.min(lines.len())].join("\n") - 91
} - 92
- 93
pub struct RecallTool; - 94
- 95
#[async_trait] - 96
impl Tool for RecallTool { - 97
fn name(&self) -> &str { - 98
"recall" - 99
} - 100
- 101
fn serves(&self) -> &'static [&'static str] { - 102
&["memory"] - 103
} - 104
- 105
fn always_loaded(&self) -> bool { - 106
true - 107
} - 108
- 109
fn description(&self) -> &str { - 110
"Reopen a past turn's full record, a presentation's canonical payload, or an evidence \ - 111
result's full content (optionally by line range). Exactly one of turn, presentation, \ - 112
or id." - 113
} - 114
- 115
fn schema(&self) -> Value { - 116
json!({ - 117
"type": "object", - 118
"properties": { - 119
"turn": { - 120
"type": "integer", - 121
"description": "Turn number (as shown in a <turns> card line) to reopen as its full record." - 122
}, - 123
"presentation": { - 124
"type": "string", - 125
"description": "Presentation id (as shown in a card line's pres:<id>) to return the canonical payload for." - 126
}, - 127
"id": { - 128
"type": "string", - 129
"description": "Evidence id (as shown in a card line's ev:<id>) to return the full tool result for." - 130
}, - 131
"range": { - 132
"type": "object", - 133
"description": "Optional 1-based inclusive line range, only meaningful with 'id'.", - 134
"properties": { - 135
"start": {"type": "integer"}, - 136
"end": {"type": "integer"} - 137
}, - 138
"required": ["start", "end"] - 139
} - 140
} - 141
}) - 142
} - 143
- 144
async fn execute(&self, args: &Value, _ctx: &ToolContext) -> ToolOutput { - 145
// `recall` is answered by the agent loop before dispatch (it needs - 146
// session access this tool object never has); reaching here is a - 147
// wiring bug, not a user-correctable error, but it is still - 148
// reported as one so a stray dispatch fails loudly instead of - 149
// silently returning nothing. - 150
match parse_recall_args(args) { - 151
Ok(_) => ToolOutput::error( - 152
r#"{"type":"internal","message":"recall must be answered by the agent loop, not dispatched directly"}"#, - 153
), - 154
Err(message) => ToolOutput::error(format!( - 155
r#"{{"type":"invalid_arguments","message":"{message}"}}"# - 156
)), - 157
} - 158
} - 159
} - 160
- 161
#[cfg(test)] - 162
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 163
mod tests { - 164
use super::*; - 165
- 166
#[test] - 167
fn parses_each_request_shape() { - 168
assert_eq!( - 169
parse_recall_args(&json!({"turn": 3})).unwrap(), - 170
RecallRequest::Turn(3) - 171
); - 172
assert_eq!( - 173
parse_recall_args(&json!({"presentation": "p1"})).unwrap(), - 174
RecallRequest::Presentation("p1".into()) - 175
); - 176
assert_eq!( - 177
parse_recall_args(&json!({"id": "ev1"})).unwrap(), - 178
RecallRequest::Id { - 179
id: "ev1".into(), - 180
range: None - 181
} - 182
); - 183
assert_eq!( - 184
parse_recall_args(&json!({"id": "ev1", "range": {"start": 2, "end": 5}})).unwrap(), - 185
RecallRequest::Id { - 186
id: "ev1".into(), - 187
range: Some((2, 5)) - 188
} - 189
); - 190
} - 191
- 192
#[test] - 193
fn rejects_zero_or_multiple_fields() { - 194
assert!(parse_recall_args(&json!({})).is_err()); - 195
assert!(parse_recall_args(&json!({"turn": 1, "id": "x"})).is_err()); - 196
assert!(parse_recall_args(&json!({"id": "x", "range": {"start": 1}})).is_err()); - 197
} - 198
- 199
#[test] - 200
fn range_slices_by_line_and_clamps() { - 201
let content = "a\nb\nc\nd\ne"; - 202
assert_eq!(apply_range(content, None), content); - 203
assert_eq!(apply_range(content, Some((2, 3))), "b\nc"); - 204
assert_eq!(apply_range(content, Some((4, 100))), "d\ne"); - 205
assert_eq!(apply_range(content, Some((100, 200))), ""); - 206
} - 207
- 208
#[tokio::test] - 209
async fn execute_is_never_the_real_answer() { - 210
let tool = RecallTool; - 211
let ctx = ToolContext::new(std::path::PathBuf::from(".")); - 212
let out = tool.execute(&json!({"turn": 1}), &ctx).await; - 213
assert!(out.is_error, "execute() must never be a live recall path"); - 214
} - 215
} - 216
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.