Skip to main content

atman_runtime/tools/
memory.rs

1use std::sync::Arc;
2
3use crate::error::RuntimeError;
4use crate::memory::MemoryId;
5use crate::memory::confession::{Confession, ConfessionStore};
6use crate::memory::goal::GoalStore;
7use crate::memory::spec::SpecStore;
8use crate::memory::todo::{Todo, TodoStatus, TodoStore};
9use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
10use crate::value::Value;
11
12pub struct MemoryGoalGet {
13    pub store: Arc<GoalStore>,
14}
15
16impl Tool for MemoryGoalGet {
17    fn name(&self) -> &str {
18        "memory.goal.get"
19    }
20
21    fn tier(&self) -> Tier {
22        Tier::Zero
23    }
24
25    fn description(&self) -> Option<&str> {
26        Some(
27            "Return the current session goal (persistent, exposed to models as an append-only context record). Empty string when unset.",
28        )
29    }
30
31    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
32        Box::pin(async move {
33            let text = self
34                .store
35                .get()
36                .map_err(|e| RuntimeError::ToolFailed(format!("goal.get: {e}")))?;
37            Ok(Value::Str(text))
38        })
39    }
40}
41
42pub struct MemoryGoalSet {
43    pub store: Arc<GoalStore>,
44}
45
46impl Tool for MemoryGoalSet {
47    fn name(&self) -> &str {
48        "memory.goal.set"
49    }
50
51    fn tier(&self) -> Tier {
52        Tier::One
53    }
54
55    fn description(&self) -> Option<&str> {
56        Some(
57            "Set the session goal — a short directive (1-2 sentences) that atman appends \
58             to model history as a versioned context record. It persists across turns; \
59             unchanged content is not appended again.\n\n\
60             Best practice: set the goal early (right after understanding the user's request), \
61             keep it concise and actionable. Update it if the user's intent changes. Clear it \
62             when the task is complete. Example: 'Fix the login bug in auth.rs and add a \
63             regression test.'",
64        )
65    }
66
67    fn input_schema(&self) -> serde_json::Value {
68        serde_json::json!({
69            "type": "object",
70            "properties": {
71                "text": {
72                    "type": "string",
73                    "description": "The goal text, 1-2 sentences. Be specific: what to do, where, and what 'done' looks like."
74                }
75            },
76            "required": ["text"]
77        })
78    }
79
80    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
81        Box::pin(async move {
82            let text = required_string(&args, "text")?;
83            self.store
84                .set(&text)
85                .map_err(|e| RuntimeError::ToolFailed(format!("goal.set: {e}")))?;
86            Ok(Value::Unit)
87        })
88    }
89}
90
91pub struct MemoryRecentTurns;
92
93const RECENT_EXCERPT_MESSAGE_CHARS: usize = 2_000;
94
95#[derive(Clone, Copy)]
96enum RecentExcerpt {
97    LegacyRecent(usize),
98    HeadTail { head: usize, tail: usize },
99}
100
101fn recent_turns_value(
102    message_count: u64,
103    turn_count: u64,
104    messages: Vec<crate::message::Message>,
105    excerpt: Option<RecentExcerpt>,
106) -> Value {
107    let excerpt = excerpt.map(|mode| match mode {
108        RecentExcerpt::LegacyRecent(limit) => bounded_recent_excerpt(&messages, limit),
109        RecentExcerpt::HeadTail { head, tail } => bounded_head_tail_excerpt(&messages, head, tail),
110    });
111    let items = messages.into_iter().map(Value::Message).collect();
112    let mut fields = vec![
113        (
114            "total_message_count".into(),
115            Value::Int(message_count as i64),
116        ),
117        ("total_turn_count".into(), Value::Int(turn_count as i64)),
118        ("items".into(), Value::List(items)),
119    ];
120    if let Some((text, truncated)) = excerpt {
121        fields.push(("excerpt".into(), Value::Str(text)));
122        fields.push(("excerpt_truncated".into(), Value::Bool(truncated)));
123    }
124    Value::Struct(fields)
125}
126
127#[derive(Default)]
128struct HeadTailExcerpt {
129    head: String,
130    tail: std::collections::VecDeque<char>,
131    head_chars: usize,
132    head_limit: usize,
133    tail_limit: usize,
134    total_chars: usize,
135}
136
137impl HeadTailExcerpt {
138    fn new(head_limit: usize, tail_limit: usize) -> Self {
139        Self {
140            head_limit,
141            tail_limit,
142            ..Self::default()
143        }
144    }
145
146    fn push(&mut self, text: &str) {
147        for ch in text.chars() {
148            if self.head_chars < self.head_limit {
149                self.head.push(ch);
150                self.head_chars += 1;
151            }
152            if self.tail_limit > 0 {
153                if self.tail.len() == self.tail_limit {
154                    self.tail.pop_front();
155                }
156                self.tail.push_back(ch);
157            }
158            self.total_chars += 1;
159        }
160    }
161
162    fn finish(self) -> (String, bool) {
163        let head_chars = self.head_chars;
164        let tail_chars = self.tail.len();
165        let overlap = head_chars
166            .saturating_add(tail_chars)
167            .saturating_sub(self.total_chars);
168        let tail = self.tail.into_iter().skip(overlap).collect::<String>();
169        let truncated = self.total_chars > head_chars.saturating_add(tail_chars);
170        if !truncated {
171            return (self.head + &tail, false);
172        }
173        if self.head.is_empty() {
174            return (tail, true);
175        }
176        if tail.is_empty() {
177            return (self.head, true);
178        }
179        let omitted = self
180            .total_chars
181            .saturating_sub(head_chars.saturating_add(tail_chars));
182        (
183            format!(
184                "{}\n\n[... omitted {omitted} chars ...]\n\n{tail}",
185                self.head
186            ),
187            true,
188        )
189    }
190}
191
192fn visit_message_excerpt_segments(
193    message: &crate::message::Message,
194    mut visit: impl FnMut(&str) -> bool,
195) {
196    use crate::message::MessagePart;
197
198    if visit(&format!("[{}]", message.role.as_str())) {
199        return;
200    }
201    for part in &message.parts {
202        let stop = match part {
203            MessagePart::FinalAnswerSummary { .. } => false,
204            MessagePart::ContextRecord(record) => {
205                let label = format!("\n[context {}@{}]\n", record.key(), record.revision());
206                visit(&label) || visit(&record.render_for_model())
207            }
208            MessagePart::CompactSummary { summary, .. } => visit("\nsummary: ") || visit(summary),
209            MessagePart::Text { text } => visit("\ntext: ") || visit(text),
210            MessagePart::Thinking { .. } => visit("\n[thinking omitted]"),
211            MessagePart::Image { .. } => visit("\n[image]"),
212            MessagePart::ToolUse { name, intent, .. } => {
213                visit("\ntool_call: ")
214                    || visit(name)
215                    || intent
216                        .as_ref()
217                        .is_some_and(|intent| visit(" — ") || visit(intent.as_str()))
218            }
219            MessagePart::ToolResult {
220                tool_use_id,
221                content,
222                is_error,
223            } => {
224                let status = if *is_error { "error" } else { "ok" };
225                visit(&format!("\ntool_result {tool_use_id} ({status}): ")) || visit(content)
226            }
227        };
228        if stop {
229            return;
230        }
231    }
232}
233
234fn bounded_head_tail_excerpt(
235    messages: &[crate::message::Message],
236    head_chars: usize,
237    tail_chars: usize,
238) -> (String, bool) {
239    let mut excerpt = HeadTailExcerpt::new(head_chars, tail_chars);
240    for (index, message) in messages.iter().enumerate() {
241        if index > 0 {
242            excerpt.push("\n\n");
243        }
244        visit_message_excerpt_segments(message, |segment| {
245            excerpt.push(segment);
246            false
247        });
248    }
249    excerpt.finish()
250}
251
252fn non_negative_excerpt_field(value: &Value, name: &str) -> Result<usize, RuntimeError> {
253    match value.field(name) {
254        Some(Value::Int(value)) if *value >= 0 => Ok(*value as usize),
255        Some(other) => Err(RuntimeError::TypeMismatch {
256            expected: format!("non-negative int for excerpt.{name}"),
257            actual: other.kind_name().into(),
258        }),
259        None => Ok(0),
260    }
261}
262
263fn recent_excerpt_arg(args: &ToolArgs) -> Result<Option<RecentExcerpt>, RuntimeError> {
264    let legacy = args.named("excerpt_chars");
265    let head_tail = args.named("excerpt");
266    if legacy.is_some() && head_tail.is_some() {
267        return Err(RuntimeError::ToolFailed(
268            "memory.recent_turns: choose `excerpt` or `excerpt_chars`, not both".into(),
269        ));
270    }
271    if let Some(value) = head_tail {
272        let Value::Struct(_) = value else {
273            return Err(RuntimeError::TypeMismatch {
274                expected: "struct with non-negative `head` and/or `tail`".into(),
275                actual: value.kind_name().into(),
276            });
277        };
278        return Ok(Some(RecentExcerpt::HeadTail {
279            head: non_negative_excerpt_field(value, "head")?,
280            tail: non_negative_excerpt_field(value, "tail")?,
281        }));
282    }
283    match legacy {
284        Some(Value::Int(value)) if *value >= 0 => {
285            Ok(Some(RecentExcerpt::LegacyRecent(*value as usize)))
286        }
287        Some(other) => Err(RuntimeError::TypeMismatch {
288            expected: "non-negative int".into(),
289            actual: other.kind_name().into(),
290        }),
291        None => Ok(None),
292    }
293}
294
295fn bounded_recent_excerpt(
296    messages: &[crate::message::Message],
297    max_chars: usize,
298) -> (String, bool) {
299    let mut remaining = max_chars;
300    let mut chunks = Vec::new();
301    let mut truncated = false;
302    for message in messages.iter().rev() {
303        let separator_chars = usize::from(!chunks.is_empty()) * 2;
304        if remaining <= separator_chars {
305            truncated = true;
306            break;
307        }
308        let message_limit = remaining
309            .saturating_sub(separator_chars)
310            .min(RECENT_EXCERPT_MESSAGE_CHARS);
311        let (chunk, message_truncated) = bounded_message_excerpt(message, message_limit);
312        remaining = remaining.saturating_sub(separator_chars + chunk.chars().count());
313        chunks.push(chunk);
314        truncated |= message_truncated;
315    }
316    if chunks.len() < messages.len() {
317        truncated = true;
318    }
319    chunks.reverse();
320    (chunks.join("\n\n"), truncated)
321}
322
323fn bounded_message_excerpt(message: &crate::message::Message, max_chars: usize) -> (String, bool) {
324    fn push_bounded(out: &mut String, used: &mut usize, max: usize, text: &str) -> bool {
325        for ch in text.chars() {
326            if *used == max {
327                return true;
328            }
329            out.push(ch);
330            *used += 1;
331        }
332        false
333    }
334
335    let mut out = String::new();
336    let mut used = 0;
337    let mut truncated = false;
338    visit_message_excerpt_segments(message, |segment| {
339        let segment_truncated = push_bounded(&mut out, &mut used, max_chars, segment);
340        truncated |= segment_truncated;
341        segment_truncated
342    });
343    (out, truncated)
344}
345
346impl Tool for MemoryRecentTurns {
347    fn name(&self) -> &str {
348        "memory.recent_turns"
349    }
350
351    fn tier(&self) -> Tier {
352        Tier::Zero
353    }
354
355    fn description(&self) -> Option<&str> {
356        Some(
357            "Return the last N Message values (user + assistant + tool_result) from the \
358             current session's event log so a flow can hand the code agent a sliding \
359             history window. `items` remain lossless; `excerpt: {head, tail}` returns a \
360             bounded text excerpt retaining independently selected transcript edges. \
361             `excerpt_chars` remains a legacy recent-first budget. Reads from disk; cost \
362             O(events file size).",
363        )
364    }
365
366    fn input_schema(&self) -> serde_json::Value {
367        serde_json::json!({
368            "type": "object",
369            "properties": {
370                "n": {"type": "integer", "description": "Max complete turns to return (default 10)"},
371                "excerpt": {
372                    "type": "object",
373                    "description": "Also return an `excerpt` retaining independently bounded transcript edges without changing lossless `items`.",
374                    "properties": {
375                        "head": {"type": "integer", "minimum": 0, "description": "Characters retained from the start of the selected transcript."},
376                        "tail": {"type": "integer", "minimum": 0, "description": "Characters retained from the end of the selected transcript."}
377                    },
378                    "additionalProperties": false
379                },
380                "excerpt_chars": {"type": "integer", "minimum": 0, "description": "Legacy recent-first excerpt budget; cannot be combined with `excerpt`."}
381            }
382        })
383    }
384
385    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
386        Box::pin(async move {
387            let n = match args.named("n").or_else(|| args.positional(0).ok()) {
388                Some(Value::Int(k)) if *k >= 0 => *k as usize,
389                Some(other) => {
390                    return Err(RuntimeError::TypeMismatch {
391                        expected: "non-negative int".into(),
392                        actual: other.kind_name().into(),
393                    });
394                }
395                None => 10,
396            };
397            let excerpt = recent_excerpt_arg(&args)?;
398            if n == 0 {
399                if let Some(cb) = &ctx.on_memory_recent {
400                    cb(0);
401                }
402                return Ok(recent_turns_value(0, 0, Vec::new(), excerpt));
403            }
404            // Sub-agent path: session_messages has the child's local message list.
405            if let Some(msgs) = ctx.session_messages.as_ref() {
406                let (total, recent) = crate::history_store::recent_turn_messages(msgs, n);
407                if let Some(cb) = &ctx.on_memory_recent {
408                    cb(recent.len() as u16);
409                }
410                return Ok(recent_turns_value(
411                    msgs.len() as u64,
412                    total,
413                    recent,
414                    excerpt,
415                ));
416            }
417            // Main-agent path: delegate to HistoryStore.
418            let Some(store) = ctx.history_store.clone() else {
419                return Err(RuntimeError::ToolFailed(
420                    "memory.recent_turns: no history store on context".into(),
421                ));
422            };
423            let (message_count, turn_count, msgs) =
424                tokio::task::spawn_blocking(move || store.recent(n))
425                    .await
426                    .map_err(|e| RuntimeError::ToolFailed(format!("recent_turns: {e}")))??;
427            if let Some(cb) = &ctx.on_memory_recent {
428                cb(msgs.len() as u16);
429            }
430            Ok(recent_turns_value(message_count, turn_count, msgs, excerpt))
431        })
432    }
433}
434
435pub struct MemoryGoalClear {
436    pub store: Arc<GoalStore>,
437}
438
439impl Tool for MemoryGoalClear {
440    fn name(&self) -> &str {
441        "memory.goal.clear"
442    }
443
444    fn tier(&self) -> Tier {
445        Tier::One
446    }
447
448    fn description(&self) -> Option<&str> {
449        Some(
450            "Clear the session goal. Call this when the task is complete or the user \
451             changes direction entirely. Returns nothing.",
452        )
453    }
454
455    fn input_schema(&self) -> serde_json::Value {
456        serde_json::json!({"type": "object"})
457    }
458
459    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
460        Box::pin(async move {
461            self.store
462                .clear()
463                .map_err(|e| RuntimeError::ToolFailed(format!("goal.clear: {e}")))?;
464            Ok(Value::Unit)
465        })
466    }
467}
468
469pub struct MemoryTodoSet {
470    pub store: Arc<TodoStore>,
471}
472
473impl Tool for MemoryTodoSet {
474    fn name(&self) -> &str {
475        "memory.todo.set"
476    }
477
478    fn tier(&self) -> Tier {
479        Tier::One
480    }
481
482    fn description(&self) -> Option<&str> {
483        Some(
484            "Create a concrete execution todo. Returns the todo id (UUID string) — \
485             save it for memory.todo.done / memory.todo.cancel / memory.todo.delete.\n\n\
486             Todos are for short, trackable work items, usually inside the current \
487             plan step. Use plan.write/read/tick for the high-level ordered route \
488             through a multi-step task. Do not create todos that simply mirror plan \
489             steps; do not create a todo when one plan step is enough.\n\n\
490             Best practice: create a todo for each discrete execution item that \
491             should stay visible while you work. Keep `where` specific (file path \
492             or module), `why` one sentence, `how` a brief approach, \
493             `expected_result` the verification criteria. Don't create todos for \
494             trivial steps — only for things the user would want to track.\n\n\
495             To modify an existing todo, cancel the old one then create a new one. \
496             There is no update tool.",
497        )
498    }
499
500    fn input_schema(&self) -> serde_json::Value {
501        serde_json::json!({
502            "type": "object",
503            "properties": {
504                "where": {"type": "string", "description": "Where to do it (file path, module, etc.)"},
505                "why": {"type": "string", "description": "Why this needs doing"},
506                "how": {"type": "string", "description": "How to do it (brief approach)"},
507                "expected_result": {"type": "string", "description": "What success looks like"}
508            },
509            "required": ["where", "why", "how", "expected_result"]
510        })
511    }
512
513    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
514        Box::pin(async move {
515            let where_ = required_string(&args, "where")?;
516            let why = required_string(&args, "why")?;
517            let how = required_string(&args, "how")?;
518            let expected_result = required_string(&args, "expected_result")?;
519            let todo = Todo {
520                id: MemoryId::now(),
521                where_,
522                why,
523                how,
524                expected_result,
525                status: TodoStatus::Pending,
526            };
527            let id = self.store.add(todo).await?;
528            Ok(Value::Str(id.to_string()))
529        })
530    }
531}
532
533pub struct MemoryTodoDone {
534    pub store: Arc<TodoStore>,
535}
536
537impl Tool for MemoryTodoDone {
538    fn name(&self) -> &str {
539        "memory.todo.done"
540    }
541
542    fn tier(&self) -> Tier {
543        Tier::One
544    }
545
546    fn description(&self) -> Option<&str> {
547        Some(
548            "Mark a todo as done. Once done, a todo cannot be un-done. \
549             The id must be the UUID string returned by memory.todo.set. \
550             Returns \"ok\" on success (including if already done).",
551        )
552    }
553
554    fn input_schema(&self) -> serde_json::Value {
555        serde_json::json!({
556            "type": "object",
557            "properties": {
558                "id": {"type": "string", "description": "The UUID returned by memory.todo.set (e.g. \"019f5500-9a53-7800-8083-b608fdc4124a\")"}
559            },
560            "required": ["id"]
561        })
562    }
563
564    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
565        Box::pin(async move {
566            let id = required_string(&args, "id")?;
567            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
568                RuntimeError::ToolFailed(format!(
569                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
570                ))
571            })?;
572            self.store
573                .set_status(&MemoryId(uuid), TodoStatus::Done)
574                .await?;
575            Ok(Value::Str("ok".into()))
576        })
577    }
578}
579
580pub struct MemoryTodoCancel {
581    pub store: Arc<TodoStore>,
582}
583
584impl Tool for MemoryTodoCancel {
585    fn name(&self) -> &str {
586        "memory.todo.cancel"
587    }
588
589    fn tier(&self) -> Tier {
590        Tier::One
591    }
592
593    fn description(&self) -> Option<&str> {
594        Some(
595            "Cancel a todo. Once cancelled, a todo cannot be re-activated. \
596             The id must be the UUID string returned by memory.todo.set. \
597             Returns \"ok\" on success (including if already cancelled).",
598        )
599    }
600
601    fn input_schema(&self) -> serde_json::Value {
602        serde_json::json!({
603            "type": "object",
604            "properties": {
605                "id": {"type": "string", "description": "The UUID returned by memory.todo.set"}
606            },
607            "required": ["id"]
608        })
609    }
610
611    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
612        Box::pin(async move {
613            let id = required_string(&args, "id")?;
614            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
615                RuntimeError::ToolFailed(format!(
616                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
617                ))
618            })?;
619            self.store
620                .set_status(&MemoryId(uuid), TodoStatus::Cancelled)
621                .await?;
622            Ok(Value::Str("ok".into()))
623        })
624    }
625}
626
627pub struct MemoryTodoDelete {
628    pub store: Arc<TodoStore>,
629}
630
631impl Tool for MemoryTodoDelete {
632    fn name(&self) -> &str {
633        "memory.todo.delete"
634    }
635
636    fn tier(&self) -> Tier {
637        Tier::One
638    }
639
640    fn description(&self) -> Option<&str> {
641        Some(
642            "Permanently delete a todo. Unlike done/cancel, the todo is removed \
643             entirely from the list. Use for todos created by mistake. \
644             The id must be the UUID string returned by memory.todo.set.",
645        )
646    }
647
648    fn input_schema(&self) -> serde_json::Value {
649        serde_json::json!({
650            "type": "object",
651            "properties": {
652                "id": {"type": "string", "description": "The UUID returned by memory.todo.set"}
653            },
654            "required": ["id"]
655        })
656    }
657
658    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
659        Box::pin(async move {
660            let id = required_string(&args, "id")?;
661            let uuid = uuid::Uuid::parse_str(&id).map_err(|e| {
662                RuntimeError::ToolFailed(format!(
663                    "bad todo id: {e}. The id must be the UUID returned by memory.todo.set."
664                ))
665            })?;
666            self.store.delete(&MemoryId(uuid)).await?;
667            Ok(Value::Str("ok".into()))
668        })
669    }
670}
671
672pub struct MemoryTodoList {
673    pub store: Arc<TodoStore>,
674}
675
676impl Tool for MemoryTodoList {
677    fn name(&self) -> &str {
678        "memory.todo.list"
679    }
680
681    fn tier(&self) -> Tier {
682        Tier::Zero
683    }
684
685    fn description(&self) -> Option<&str> {
686        Some(
687            "List all todos in the current session. Returns an array of \
688             {id, where, why, how, expected_result, status}. \
689             status is one of: pending, done, cancelled. \
690             Call this to check concrete work items before starting or resuming a \
691             plan step.",
692        )
693    }
694
695    fn input_schema(&self) -> serde_json::Value {
696        serde_json::json!({"type": "object"})
697    }
698
699    fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
700        Box::pin(async move {
701            let todos = self.store.list().await?;
702            let items: Vec<Value> = todos
703                .into_iter()
704                .map(|t| {
705                    Value::Struct(vec![
706                        ("id".into(), Value::Str(t.id.to_string())),
707                        ("where".into(), Value::Str(t.where_)),
708                        ("why".into(), Value::Str(t.why)),
709                        ("how".into(), Value::Str(t.how)),
710                        ("expected_result".into(), Value::Str(t.expected_result)),
711                        (
712                            "status".into(),
713                            Value::Str(format!("{:?}", t.status).to_lowercase()),
714                        ),
715                    ])
716                })
717                .collect();
718            Ok(Value::List(items))
719        })
720    }
721}
722
723pub struct MemoryConfess {
724    pub store: Arc<ConfessionStore>,
725}
726
727impl Tool for MemoryConfess {
728    fn name(&self) -> &str {
729        "memory.confess"
730    }
731
732    fn tier(&self) -> Tier {
733        Tier::One
734    }
735
736    fn description(&self) -> Option<&str> {
737        Some(
738            "Record a confession when the agent broke a rule. Anchors are auto-filled from \
739             the current turn / flow_run / event_seq. Returns the new confession id.",
740        )
741    }
742
743    fn input_schema(&self) -> serde_json::Value {
744        serde_json::json!({
745            "type": "object",
746            "properties": {
747                "trigger": {"type": "string", "description": "What the user or watcher noticed."},
748                "rule_violated": {"type": "string", "description": "Name of the red-line rule."},
749                "what_i_did": {"type": "string", "description": "The concrete mistake."},
750                "why": {"type": "string", "description": "The reasoning that led there."},
751                "mitigation": {"type": "string", "description": "What will prevent recurrence."},
752                "anchors": {
753                    "type": "array",
754                    "items": {"type": "string"},
755                    "description": "Optional extra anchor strings (auto-filled ones stay)."
756                }
757            },
758            "required": ["trigger", "rule_violated", "what_i_did", "why", "mitigation"]
759        })
760    }
761
762    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
763        let anchors = collect_anchors(&args, ctx);
764        Box::pin(async move {
765            let trigger = required_string(&args, "trigger")?;
766            let rule_violated = required_string(&args, "rule_violated")?;
767            let what_i_did = required_string(&args, "what_i_did")?;
768            let why = required_string(&args, "why")?;
769            let mitigation = required_string(&args, "mitigation")?;
770            let confession = Confession {
771                id: MemoryId::now(),
772                trigger,
773                rule_violated,
774                what_i_did,
775                why,
776                mitigation,
777                anchors,
778                created_at: chrono::Utc::now(),
779            };
780            let id = self.store.append(confession).await?;
781            Ok(Value::Str(id.to_string()))
782        })
783    }
784}
785
786fn collect_anchors(args: &ToolArgs, ctx: &ToolCtx) -> Vec<String> {
787    let mut out = Vec::new();
788    if let Some(flow_run) = &ctx.flow_run_id {
789        out.push(format!("flow_run:{flow_run}"));
790    }
791    if let Some(turn) = &ctx.turn_id {
792        out.push(format!("turn:{turn}"));
793    }
794    if let Some(seq) = ctx.event_seq {
795        out.push(format!("event_seq:{seq}"));
796    }
797    if let Some(Value::List(items)) = args.named("anchors") {
798        for item in items {
799            if let Value::Str(s) = item {
800                out.push(s.clone());
801            }
802        }
803    }
804    out
805}
806
807pub struct MemorySpecStatus {
808    pub store: Arc<SpecStore>,
809}
810
811impl Tool for MemorySpecStatus {
812    fn name(&self) -> &str {
813        "memory.spec.status"
814    }
815    fn tier(&self) -> Tier {
816        Tier::Zero
817    }
818
819    fn description(&self) -> Option<&str> {
820        Some(
821            "Return progress counters for a named spec feature. Use it to check the current phase, update count, and deviation count before continuing spec-driven work.",
822        )
823    }
824
825    fn input_schema(&self) -> serde_json::Value {
826        serde_json::json!({
827            "type": "object",
828            "properties": {
829                "feature": {"type": "string", "description": "Spec feature name to inspect."}
830            },
831            "required": ["feature"]
832        })
833    }
834
835    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
836        Box::pin(async move {
837            let feature = required_string(&args, "feature")?;
838            let st = self.store.status(&feature).await?;
839            let approved = st.approved_design_revision.is_some();
840            Ok(Value::Struct(vec![
841                ("feature".into(), Value::Str(st.feature)),
842                ("phase".into(), Value::Str(st.phase)),
843                ("entry_count".into(), Value::Int(st.entry_count as i64)),
844                (
845                    "deviation_count".into(),
846                    Value::Int(st.deviation_count as i64),
847                ),
848                (
849                    "design_revision".into(),
850                    st.design_revision.map(Value::Str).unwrap_or(Value::Unit),
851                ),
852                (
853                    "approved_design_revision".into(),
854                    st.approved_design_revision
855                        .map(Value::Str)
856                        .unwrap_or(Value::Unit),
857                ),
858                ("approved".into(), Value::Bool(approved)),
859            ]))
860        })
861    }
862}
863
864pub struct MemorySpecRead {
865    pub store: Arc<SpecStore>,
866}
867
868impl Tool for MemorySpecRead {
869    fn name(&self) -> &str {
870        "memory.spec.read"
871    }
872
873    fn tier(&self) -> Tier {
874        Tier::Zero
875    }
876
877    fn description(&self) -> Option<&str> {
878        Some("Read stored entries for a project-scoped spec feature and optional phase.")
879    }
880
881    fn input_schema(&self) -> serde_json::Value {
882        serde_json::json!({
883            "type": "object",
884            "properties": {
885                "feature": {"type": "string"},
886                "phase": {"type": "string"}
887            },
888            "required": ["feature"]
889        })
890    }
891
892    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
893        Box::pin(async move {
894            let feature = required_string(&args, "feature")?;
895            let phase = args.named("phase").and_then(|value| match value {
896                Value::Str(text) => Some(text.as_str()),
897                _ => None,
898            });
899            let entries = self.store.entries(&feature).await?;
900            let entries = entries
901                .into_iter()
902                .filter(|entry| phase.is_none_or(|phase| entry.phase == phase))
903                .map(|entry| {
904                    Value::Struct(vec![
905                        ("id".into(), Value::Str(entry.id.to_string())),
906                        ("phase".into(), Value::Str(entry.phase)),
907                        ("content".into(), Value::Str(entry.content)),
908                    ])
909                })
910                .collect();
911            Ok(Value::Struct(vec![
912                ("entries".into(), Value::List(entries)),
913                (
914                    "markdown".into(),
915                    match phase {
916                        Some(phase) => {
917                            Value::Str(self.store.phase_markdown(&feature, phase).await?)
918                        }
919                        None => Value::Unit,
920                    },
921                ),
922                (
923                    "file_revision".into(),
924                    match phase {
925                        Some(phase) => {
926                            Value::Str(self.store.materialized_revision(&feature, phase).await?)
927                        }
928                        None => Value::Unit,
929                    },
930                ),
931                (
932                    "phase_revision".into(),
933                    match phase {
934                        Some(phase) => self
935                            .store
936                            .phase_revision(&feature, phase)
937                            .await?
938                            .map(Value::Str)
939                            .unwrap_or(Value::Unit),
940                        None => Value::Unit,
941                    },
942                ),
943                (
944                    "design_revision".into(),
945                    self.store
946                        .design_revision(&feature)
947                        .await?
948                        .map(Value::Str)
949                        .unwrap_or(Value::Unit),
950                ),
951            ]))
952        })
953    }
954}
955
956pub struct MemorySpecReview {
957    pub store: Arc<SpecStore>,
958}
959
960impl Tool for MemorySpecReview {
961    fn name(&self) -> &str {
962        "memory.spec.review"
963    }
964
965    fn tier(&self) -> Tier {
966        Tier::One
967    }
968
969    fn description(&self) -> Option<&str> {
970        Some("Record the user's review of the exact current design revision.")
971    }
972
973    fn input_schema(&self) -> serde_json::Value {
974        serde_json::json!({
975            "type": "object",
976            "properties": {
977                "feature": {"type": "string"},
978                "design_revision": {"type": "string"},
979                "approved": {"type": "boolean"}
980            },
981            "required": ["feature", "design_revision", "approved"]
982        })
983    }
984
985    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
986        Box::pin(async move {
987            let feature = required_string(&args, "feature")?;
988            let revision = required_string(&args, "design_revision")?;
989            let approved = match args.named("approved") {
990                Some(Value::Bool(value)) => *value,
991                _ => return Err(crate::error::RuntimeError::MissingArg("approved".into())),
992            };
993            let record = self.store.review(&feature, &revision, approved).await?;
994            Ok(Value::Struct(vec![
995                ("feature".into(), Value::Str(record.feature)),
996                ("design_revision".into(), Value::Str(record.design_revision)),
997                ("approved".into(), Value::Bool(record.approved)),
998            ]))
999        })
1000    }
1001}
1002
1003pub struct MemorySpecMaterialize {
1004    pub store: Arc<SpecStore>,
1005}
1006
1007impl Tool for MemorySpecMaterialize {
1008    fn name(&self) -> &str {
1009        "memory.spec.materialize"
1010    }
1011
1012    fn tier(&self) -> Tier {
1013        Tier::One
1014    }
1015
1016    fn description(&self) -> Option<&str> {
1017        Some(
1018            "Materialize project-scoped JSONL spec state to Markdown with revision conflict protection. Set phase for a phase document; omit it for the aggregate IMPLEMENTATION.md.",
1019        )
1020    }
1021
1022    fn input_schema(&self) -> serde_json::Value {
1023        serde_json::json!({
1024            "type": "object",
1025            "properties": {
1026                "feature": {"type": "string"},
1027                "phase": {"type": "string", "enum": ["research", "design", "implementation", "testing", "retrospective"]},
1028                "expected_revision": {"type": "string"}
1029            },
1030            "required": ["feature"]
1031        })
1032    }
1033
1034    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1035        Box::pin(async move {
1036            let feature = required_string(&args, "feature")?;
1037            let expected = match args.named("expected_revision") {
1038                Some(Value::Str(value)) => Some(value.as_str()),
1039                _ => None,
1040            };
1041            let phase = match args.named("phase") {
1042                Some(Value::Str(value)) => Some(value.as_str()),
1043                _ => None,
1044            };
1045            let result = self
1046                .store
1047                .materialize_phase(&feature, phase, expected)
1048                .await?;
1049            Ok(Value::Struct(vec![
1050                ("path".into(), Value::Str(result.path.display().to_string())),
1051                ("revision".into(), Value::Str(result.revision)),
1052                ("changed".into(), Value::Bool(result.changed)),
1053            ]))
1054        })
1055    }
1056}
1057
1058pub struct MemorySpecUpdate {
1059    pub store: Arc<SpecStore>,
1060}
1061
1062impl Tool for MemorySpecUpdate {
1063    fn name(&self) -> &str {
1064        "memory.spec.update"
1065    }
1066    fn tier(&self) -> Tier {
1067        Tier::One
1068    }
1069
1070    fn description(&self) -> Option<&str> {
1071        Some(
1072            "Append a progress entry for a spec feature and phase. Use it to persist research, design, implementation, or verification notes as spec work advances.",
1073        )
1074    }
1075
1076    fn input_schema(&self) -> serde_json::Value {
1077        serde_json::json!({
1078            "type": "object",
1079            "properties": {
1080                "feature": {"type": "string", "description": "Spec feature name to update."},
1081                "phase": {"type": "string", "description": "Spec phase or section name, such as research, design, implementation, or verification."},
1082                "content": {"type": "string", "description": "Progress entry content to append."}
1083            },
1084            "required": ["feature", "phase", "content"]
1085        })
1086    }
1087
1088    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1089        Box::pin(async move {
1090            let feature = required_string(&args, "feature")?;
1091            let phase = required_string(&args, "phase")?;
1092            let content = required_string(&args, "content")?;
1093            let entry = self.store.update(&feature, &phase, content).await?;
1094            Ok(Value::Struct(vec![
1095                ("id".into(), Value::Str(entry.id.to_string())),
1096                ("feature".into(), Value::Str(entry.feature)),
1097                ("phase".into(), Value::Str(entry.phase)),
1098            ]))
1099        })
1100    }
1101}
1102
1103pub struct MemorySpecDeviate {
1104    pub store: Arc<SpecStore>,
1105}
1106
1107impl Tool for MemorySpecDeviate {
1108    fn name(&self) -> &str {
1109        "memory.spec.deviate"
1110    }
1111    fn tier(&self) -> Tier {
1112        Tier::One
1113    }
1114
1115    fn description(&self) -> Option<&str> {
1116        Some(
1117            "Record an intentional deviation from a spec section. Use it when implementation differs from the written plan and the delta plus reason must be preserved.",
1118        )
1119    }
1120
1121    fn input_schema(&self) -> serde_json::Value {
1122        serde_json::json!({
1123            "type": "object",
1124            "properties": {
1125                "feature": {"type": "string", "description": "Spec feature name that owns the deviation."},
1126                "section": {"type": "string", "description": "Spec section or decision being changed."},
1127                "delta": {"type": "string", "description": "What changed from the spec."},
1128                "reason": {"type": "string", "description": "Why the deviation is necessary."}
1129            },
1130            "required": ["feature", "section", "delta", "reason"]
1131        })
1132    }
1133
1134    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1135        Box::pin(async move {
1136            let feature = required_string(&args, "feature")?;
1137            let section = required_string(&args, "section")?;
1138            let delta = required_string(&args, "delta")?;
1139            let reason = required_string(&args, "reason")?;
1140            let dev = self.store.deviate(&feature, section, delta, reason).await?;
1141            Ok(Value::Struct(vec![
1142                ("id".into(), Value::Str(dev.id.to_string())),
1143                ("feature".into(), Value::Str(dev.feature)),
1144                ("section".into(), Value::Str(dev.section)),
1145            ]))
1146        })
1147    }
1148}
1149
1150pub struct MemoryFetchConfessions {
1151    pub store: Arc<ConfessionStore>,
1152}
1153
1154impl Tool for MemoryFetchConfessions {
1155    fn name(&self) -> &str {
1156        "memory.fetch_confessions"
1157    }
1158
1159    fn tier(&self) -> Tier {
1160        Tier::Zero
1161    }
1162
1163    fn description(&self) -> Option<&str> {
1164        Some(
1165            "Fetch past confession records about rule violations, optionally filtered by trigger text. Use it to recall prior mistakes and mitigations before repeating risky work.",
1166        )
1167    }
1168
1169    fn input_schema(&self) -> serde_json::Value {
1170        serde_json::json!({
1171            "type": "object",
1172            "properties": {
1173                "trigger": {"type": "string", "description": "Optional trigger substring to search for; omit to list all confession records."}
1174            }
1175        })
1176    }
1177
1178    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1179        Box::pin(async move {
1180            let items = match args.named("trigger") {
1181                Some(Value::Str(needle)) => self.store.find_by_trigger(needle).await?,
1182                _ => self.store.list().await?,
1183            };
1184            let list = items
1185                .into_iter()
1186                .map(|c| {
1187                    Value::Struct(vec![
1188                        ("id".into(), Value::Str(c.id.to_string())),
1189                        ("trigger".into(), Value::Str(c.trigger)),
1190                        ("rule_violated".into(), Value::Str(c.rule_violated)),
1191                        ("what_i_did".into(), Value::Str(c.what_i_did)),
1192                        ("why".into(), Value::Str(c.why)),
1193                        ("mitigation".into(), Value::Str(c.mitigation)),
1194                    ])
1195                })
1196                .collect();
1197            Ok(Value::List(list))
1198        })
1199    }
1200}
1201
1202pub struct MemoryHistorySearch;
1203
1204impl Tool for MemoryHistorySearch {
1205    fn name(&self) -> &str {
1206        "memory.history.search"
1207    }
1208
1209    fn tier(&self) -> Tier {
1210        Tier::Zero
1211    }
1212
1213    fn description(&self) -> Option<&str> {
1214        Some(
1215            "Full-text search the current session's chat history (or optionally every session \
1216             in the same project). Use it to recall past turns that fell out of your working \
1217             context — e.g. `plan we agreed on this morning`, `which files did we read`, \
1218             `error the user reported earlier`. NOT for searching source code; use fs.grep for \
1219             that. Params: query (FTS5 syntax, required), scope (\"session\"|\"project\", \
1220             default \"session\"), limit (int, default 10, max 50).",
1221        )
1222    }
1223
1224    fn input_schema(&self) -> serde_json::Value {
1225        serde_json::json!({
1226            "type": "object",
1227            "properties": {
1228                "query": {"type": "string"},
1229                "scope": {"type": "string", "enum": ["session", "project"], "default": "session"},
1230                "limit": {"type": "integer", "default": 10}
1231            },
1232            "required": ["query"]
1233        })
1234    }
1235
1236    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1237        Box::pin(async move {
1238            let query = required_string(&args, "query")?;
1239            let scope = match args.named("scope") {
1240                Some(Value::Str(s)) if s == "project" => HistoryScope::Project,
1241                _ => HistoryScope::Session,
1242            };
1243            let limit = match args.named("limit") {
1244                Some(Value::Int(n)) if *n > 0 => (*n as usize).min(50),
1245                _ => 10,
1246            };
1247            let Some(store) = ctx.history_store.clone() else {
1248                return Err(RuntimeError::ToolFailed(
1249                    "memory.history.search: no history store on context".into(),
1250                ));
1251            };
1252            let search_scope = match scope {
1253                HistoryScope::Project => crate::history_store::SearchScope::Project,
1254                HistoryScope::Session => crate::history_store::SearchScope::Session,
1255            };
1256            let result =
1257                tokio::task::spawn_blocking(move || store.search(&query, search_scope, limit))
1258                    .await
1259                    .map_err(|e| RuntimeError::ToolFailed(format!("history.search: {e}")))??;
1260            let hits: Vec<Value> = result
1261                .hits
1262                .into_iter()
1263                .map(|hit| {
1264                    Value::Struct(vec![
1265                        ("session_id".into(), Value::Str(hit.session_id)),
1266                        ("seq".into(), Value::Int(hit.seq as i64)),
1267                        ("ts".into(), Value::Str(hit.ts)),
1268                        ("kind".into(), Value::Str(hit.kind)),
1269                        ("snippet".into(), Value::Str(hit.snippet)),
1270                    ])
1271                })
1272                .collect();
1273            Ok(Value::Struct(vec![
1274                ("total".into(), Value::Int(result.total as i64)),
1275                ("hits".into(), Value::List(hits)),
1276            ]))
1277        })
1278    }
1279}
1280
1281pub struct MemoryHistoryRead;
1282
1283impl Tool for MemoryHistoryRead {
1284    fn name(&self) -> &str {
1285        "memory.history.read"
1286    }
1287
1288    fn tier(&self) -> Tier {
1289        Tier::Zero
1290    }
1291
1292    fn description(&self) -> Option<&str> {
1293        Some(
1294            "Paginate through past messages of a session by turn index. Prefer \
1295             memory.history.search first to find a hit, then call this for surrounding context. \
1296             Params: session_id (string, default current session's directory name), offset \
1297             (1-based turn index, default 1), limit (int, default 20, max 100), role_filter \
1298             (comma-separated: user,assistant,tool,system; default all).",
1299        )
1300    }
1301
1302    fn input_schema(&self) -> serde_json::Value {
1303        serde_json::json!({
1304            "type": "object",
1305            "properties": {
1306                "session_id": {"type": "string"},
1307                "offset": {"type": "integer", "default": 1},
1308                "limit": {"type": "integer", "default": 20},
1309                "role_filter": {"type": "string"}
1310            }
1311        })
1312    }
1313
1314    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1315        Box::pin(async move {
1316            let Some(current_dir) = ctx.session_dir.as_ref() else {
1317                return Err(RuntimeError::ToolFailed(
1318                    "memory.history.read: no session dir on context".into(),
1319                ));
1320            };
1321            let session_id = match args.named("session_id") {
1322                Some(Value::Str(sid)) if !sid.is_empty() => sid.clone(),
1323                _ => current_dir
1324                    .file_name()
1325                    .map(|n| n.to_string_lossy().into_owned())
1326                    .unwrap_or_default(),
1327            };
1328            let offset = match args.named("offset") {
1329                Some(Value::Int(n)) if *n >= 1 => *n as usize,
1330                _ => 1,
1331            };
1332            let limit = match args.named("limit") {
1333                Some(Value::Int(n)) if *n >= 1 => (*n as usize).min(100),
1334                _ => 20,
1335            };
1336            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
1337                Some(Value::Str(s)) if !s.is_empty() => Some(
1338                    s.split(',')
1339                        .map(|t| t.trim().to_lowercase())
1340                        .filter(|t| !t.is_empty())
1341                        .collect(),
1342                ),
1343                _ => None,
1344            };
1345            let Some(store) = ctx.history_store.clone() else {
1346                return Err(RuntimeError::ToolFailed(
1347                    "memory.history.read: no history store on context".into(),
1348                ));
1349            };
1350            let query = crate::history_store::HistoryQuery {
1351                session_id,
1352                offset,
1353                limit,
1354                role_filter,
1355            };
1356            let page = tokio::task::spawn_blocking(move || store.read(query))
1357                .await
1358                .map_err(|e| RuntimeError::ToolFailed(format!("history.read: {e}")))??;
1359            let item_count = page.items.len();
1360            let items: Vec<Value> = page.items.into_iter().map(Value::Message).collect();
1361            let start = offset;
1362            let end = if item_count == 0 {
1363                start
1364            } else {
1365                start + item_count - 1
1366            };
1367            let header = format!("[history: turns {start}-{end} of {}]", page.total);
1368            Ok(Value::Struct(vec![
1369                ("total".into(), Value::Int(page.total as i64)),
1370                ("offset".into(), Value::Int(page.offset as i64)),
1371                ("limit".into(), Value::Int(page.limit as i64)),
1372                ("header".into(), Value::Str(header)),
1373                ("items".into(), Value::List(items)),
1374            ]))
1375        })
1376    }
1377}
1378
1379pub struct MemoryHistoryCount;
1380
1381impl Tool for MemoryHistoryCount {
1382    fn name(&self) -> &str {
1383        "memory.history.count"
1384    }
1385
1386    fn tier(&self) -> Tier {
1387        Tier::Zero
1388    }
1389
1390    fn description(&self) -> Option<&str> {
1391        Some(
1392            "Return the total message count for a session. Lightweight — use this to check \
1393             how many messages exist before paginating with memory.history.read. \
1394             Params: session_id (string, default current session), role_filter \
1395             (comma-separated: user,assistant,tool,system; default all).",
1396        )
1397    }
1398
1399    fn input_schema(&self) -> serde_json::Value {
1400        serde_json::json!({
1401            "type": "object",
1402            "properties": {
1403                "session_id": {"type": "string"},
1404                "role_filter": {"type": "string"}
1405            }
1406        })
1407    }
1408
1409    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1410        Box::pin(async move {
1411            let Some(current_dir) = ctx.session_dir.as_ref() else {
1412                return Err(RuntimeError::ToolFailed(
1413                    "memory.history.count: no session dir on context".into(),
1414                ));
1415            };
1416            let session_id = match args.named("session_id") {
1417                Some(Value::Str(sid)) if !sid.is_empty() => sid.clone(),
1418                _ => current_dir
1419                    .file_name()
1420                    .map(|n| n.to_string_lossy().into_owned())
1421                    .unwrap_or_default(),
1422            };
1423            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
1424                Some(Value::Str(s)) if !s.is_empty() => Some(
1425                    s.split(',')
1426                        .map(|t| t.trim().to_lowercase())
1427                        .filter(|t| !t.is_empty())
1428                        .collect(),
1429                ),
1430                _ => None,
1431            };
1432            let Some(store) = ctx.history_store.clone() else {
1433                return Err(RuntimeError::ToolFailed(
1434                    "memory.history.count: no history store on context".into(),
1435                ));
1436            };
1437            let total = tokio::task::spawn_blocking(move || {
1438                let role_refs: Option<Vec<&str>> = role_filter
1439                    .as_ref()
1440                    .map(|rs| rs.iter().map(|s| s.as_str()).collect());
1441                store.count(&session_id, role_refs.as_deref())
1442            })
1443            .await
1444            .map_err(|e| RuntimeError::ToolFailed(format!("history.count: {e}")))??;
1445            Ok(Value::Int(total as i64))
1446        })
1447    }
1448}
1449
1450enum HistoryScope {
1451    Session,
1452    Project,
1453}
1454
1455fn required_string(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
1456    match args.named(name) {
1457        Some(Value::Str(s)) => Ok(s.clone()),
1458        Some(other) => Err(RuntimeError::TypeMismatch {
1459            expected: "string".into(),
1460            actual: other.kind_name().into(),
1461        }),
1462        None => Err(RuntimeError::MissingArg(name.into())),
1463    }
1464}