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            Ok(Value::Struct(vec![
840                ("feature".into(), Value::Str(st.feature)),
841                ("phase".into(), Value::Str(st.phase)),
842                ("entry_count".into(), Value::Int(st.entry_count as i64)),
843                (
844                    "deviation_count".into(),
845                    Value::Int(st.deviation_count as i64),
846                ),
847            ]))
848        })
849    }
850}
851
852pub struct MemorySpecMaterialize {
853    pub store: Arc<SpecStore>,
854}
855
856impl Tool for MemorySpecMaterialize {
857    fn name(&self) -> &str {
858        "memory.spec.materialize"
859    }
860
861    fn tier(&self) -> Tier {
862        Tier::One
863    }
864
865    fn description(&self) -> Option<&str> {
866        Some("Materialize runtime JSONL spec state to Markdown with revision conflict protection.")
867    }
868
869    fn input_schema(&self) -> serde_json::Value {
870        serde_json::json!({
871            "type": "object",
872            "properties": {
873                "feature": {"type": "string"},
874                "expected_revision": {"type": "string"}
875            },
876            "required": ["feature"]
877        })
878    }
879
880    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
881        Box::pin(async move {
882            let feature = required_string(&args, "feature")?;
883            let expected = match args.named("expected_revision") {
884                Some(Value::Str(value)) => Some(value.as_str()),
885                _ => None,
886            };
887            let result = self.store.materialize(&feature, expected).await?;
888            Ok(Value::Struct(vec![
889                ("path".into(), Value::Str(result.path.display().to_string())),
890                ("revision".into(), Value::Str(result.revision)),
891                ("changed".into(), Value::Bool(result.changed)),
892            ]))
893        })
894    }
895}
896
897pub struct MemorySpecUpdate {
898    pub store: Arc<SpecStore>,
899}
900
901impl Tool for MemorySpecUpdate {
902    fn name(&self) -> &str {
903        "memory.spec.update"
904    }
905    fn tier(&self) -> Tier {
906        Tier::One
907    }
908
909    fn description(&self) -> Option<&str> {
910        Some(
911            "Append a progress entry for a spec feature and phase. Use it to persist research, design, implementation, or verification notes as spec work advances.",
912        )
913    }
914
915    fn input_schema(&self) -> serde_json::Value {
916        serde_json::json!({
917            "type": "object",
918            "properties": {
919                "feature": {"type": "string", "description": "Spec feature name to update."},
920                "phase": {"type": "string", "description": "Spec phase or section name, such as research, design, implementation, or verification."},
921                "content": {"type": "string", "description": "Progress entry content to append."}
922            },
923            "required": ["feature", "phase", "content"]
924        })
925    }
926
927    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
928        Box::pin(async move {
929            let feature = required_string(&args, "feature")?;
930            let phase = required_string(&args, "phase")?;
931            let content = required_string(&args, "content")?;
932            let entry = self.store.update(&feature, &phase, content).await?;
933            Ok(Value::Struct(vec![
934                ("id".into(), Value::Str(entry.id.to_string())),
935                ("feature".into(), Value::Str(entry.feature)),
936                ("phase".into(), Value::Str(entry.phase)),
937            ]))
938        })
939    }
940}
941
942pub struct MemorySpecDeviate {
943    pub store: Arc<SpecStore>,
944}
945
946impl Tool for MemorySpecDeviate {
947    fn name(&self) -> &str {
948        "memory.spec.deviate"
949    }
950    fn tier(&self) -> Tier {
951        Tier::One
952    }
953
954    fn description(&self) -> Option<&str> {
955        Some(
956            "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.",
957        )
958    }
959
960    fn input_schema(&self) -> serde_json::Value {
961        serde_json::json!({
962            "type": "object",
963            "properties": {
964                "feature": {"type": "string", "description": "Spec feature name that owns the deviation."},
965                "section": {"type": "string", "description": "Spec section or decision being changed."},
966                "delta": {"type": "string", "description": "What changed from the spec."},
967                "reason": {"type": "string", "description": "Why the deviation is necessary."}
968            },
969            "required": ["feature", "section", "delta", "reason"]
970        })
971    }
972
973    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
974        Box::pin(async move {
975            let feature = required_string(&args, "feature")?;
976            let section = required_string(&args, "section")?;
977            let delta = required_string(&args, "delta")?;
978            let reason = required_string(&args, "reason")?;
979            let dev = self.store.deviate(&feature, section, delta, reason).await?;
980            Ok(Value::Struct(vec![
981                ("id".into(), Value::Str(dev.id.to_string())),
982                ("feature".into(), Value::Str(dev.feature)),
983                ("section".into(), Value::Str(dev.section)),
984            ]))
985        })
986    }
987}
988
989pub struct MemoryFetchConfessions {
990    pub store: Arc<ConfessionStore>,
991}
992
993impl Tool for MemoryFetchConfessions {
994    fn name(&self) -> &str {
995        "memory.fetch_confessions"
996    }
997
998    fn tier(&self) -> Tier {
999        Tier::Zero
1000    }
1001
1002    fn description(&self) -> Option<&str> {
1003        Some(
1004            "Fetch past confession records about rule violations, optionally filtered by trigger text. Use it to recall prior mistakes and mitigations before repeating risky work.",
1005        )
1006    }
1007
1008    fn input_schema(&self) -> serde_json::Value {
1009        serde_json::json!({
1010            "type": "object",
1011            "properties": {
1012                "trigger": {"type": "string", "description": "Optional trigger substring to search for; omit to list all confession records."}
1013            }
1014        })
1015    }
1016
1017    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1018        Box::pin(async move {
1019            let items = match args.named("trigger") {
1020                Some(Value::Str(needle)) => self.store.find_by_trigger(needle).await?,
1021                _ => self.store.list().await?,
1022            };
1023            let list = items
1024                .into_iter()
1025                .map(|c| {
1026                    Value::Struct(vec![
1027                        ("id".into(), Value::Str(c.id.to_string())),
1028                        ("trigger".into(), Value::Str(c.trigger)),
1029                        ("rule_violated".into(), Value::Str(c.rule_violated)),
1030                        ("what_i_did".into(), Value::Str(c.what_i_did)),
1031                        ("why".into(), Value::Str(c.why)),
1032                        ("mitigation".into(), Value::Str(c.mitigation)),
1033                    ])
1034                })
1035                .collect();
1036            Ok(Value::List(list))
1037        })
1038    }
1039}
1040
1041pub struct MemoryHistorySearch;
1042
1043impl Tool for MemoryHistorySearch {
1044    fn name(&self) -> &str {
1045        "memory.history.search"
1046    }
1047
1048    fn tier(&self) -> Tier {
1049        Tier::Zero
1050    }
1051
1052    fn description(&self) -> Option<&str> {
1053        Some(
1054            "Full-text search the current session's chat history (or optionally every session \
1055             in the same project). Use it to recall past turns that fell out of your working \
1056             context — e.g. `plan we agreed on this morning`, `which files did we read`, \
1057             `error the user reported earlier`. NOT for searching source code; use fs.grep for \
1058             that. Params: query (FTS5 syntax, required), scope (\"session\"|\"project\", \
1059             default \"session\"), limit (int, default 10, max 50).",
1060        )
1061    }
1062
1063    fn input_schema(&self) -> serde_json::Value {
1064        serde_json::json!({
1065            "type": "object",
1066            "properties": {
1067                "query": {"type": "string"},
1068                "scope": {"type": "string", "enum": ["session", "project"], "default": "session"},
1069                "limit": {"type": "integer", "default": 10}
1070            },
1071            "required": ["query"]
1072        })
1073    }
1074
1075    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1076        Box::pin(async move {
1077            let query = required_string(&args, "query")?;
1078            let scope = match args.named("scope") {
1079                Some(Value::Str(s)) if s == "project" => HistoryScope::Project,
1080                _ => HistoryScope::Session,
1081            };
1082            let limit = match args.named("limit") {
1083                Some(Value::Int(n)) if *n > 0 => (*n as usize).min(50),
1084                _ => 10,
1085            };
1086            let Some(store) = ctx.history_store.clone() else {
1087                return Err(RuntimeError::ToolFailed(
1088                    "memory.history.search: no history store on context".into(),
1089                ));
1090            };
1091            let search_scope = match scope {
1092                HistoryScope::Project => crate::history_store::SearchScope::Project,
1093                HistoryScope::Session => crate::history_store::SearchScope::Session,
1094            };
1095            let result =
1096                tokio::task::spawn_blocking(move || store.search(&query, search_scope, limit))
1097                    .await
1098                    .map_err(|e| RuntimeError::ToolFailed(format!("history.search: {e}")))??;
1099            let hits: Vec<Value> = result
1100                .hits
1101                .into_iter()
1102                .map(|hit| {
1103                    Value::Struct(vec![
1104                        ("session_id".into(), Value::Str(hit.session_id)),
1105                        ("seq".into(), Value::Int(hit.seq as i64)),
1106                        ("ts".into(), Value::Str(hit.ts)),
1107                        ("kind".into(), Value::Str(hit.kind)),
1108                        ("snippet".into(), Value::Str(hit.snippet)),
1109                    ])
1110                })
1111                .collect();
1112            Ok(Value::Struct(vec![
1113                ("total".into(), Value::Int(result.total as i64)),
1114                ("hits".into(), Value::List(hits)),
1115            ]))
1116        })
1117    }
1118}
1119
1120pub struct MemoryHistoryRead;
1121
1122impl Tool for MemoryHistoryRead {
1123    fn name(&self) -> &str {
1124        "memory.history.read"
1125    }
1126
1127    fn tier(&self) -> Tier {
1128        Tier::Zero
1129    }
1130
1131    fn description(&self) -> Option<&str> {
1132        Some(
1133            "Paginate through past messages of a session by turn index. Prefer \
1134             memory.history.search first to find a hit, then call this for surrounding context. \
1135             Params: session_id (string, default current session's directory name), offset \
1136             (1-based turn index, default 1), limit (int, default 20, max 100), role_filter \
1137             (comma-separated: user,assistant,tool,system; default all).",
1138        )
1139    }
1140
1141    fn input_schema(&self) -> serde_json::Value {
1142        serde_json::json!({
1143            "type": "object",
1144            "properties": {
1145                "session_id": {"type": "string"},
1146                "offset": {"type": "integer", "default": 1},
1147                "limit": {"type": "integer", "default": 20},
1148                "role_filter": {"type": "string"}
1149            }
1150        })
1151    }
1152
1153    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1154        Box::pin(async move {
1155            let Some(current_dir) = ctx.session_dir.as_ref() else {
1156                return Err(RuntimeError::ToolFailed(
1157                    "memory.history.read: no session dir on context".into(),
1158                ));
1159            };
1160            let session_id = match args.named("session_id") {
1161                Some(Value::Str(sid)) if !sid.is_empty() => sid.clone(),
1162                _ => current_dir
1163                    .file_name()
1164                    .map(|n| n.to_string_lossy().into_owned())
1165                    .unwrap_or_default(),
1166            };
1167            let offset = match args.named("offset") {
1168                Some(Value::Int(n)) if *n >= 1 => *n as usize,
1169                _ => 1,
1170            };
1171            let limit = match args.named("limit") {
1172                Some(Value::Int(n)) if *n >= 1 => (*n as usize).min(100),
1173                _ => 20,
1174            };
1175            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
1176                Some(Value::Str(s)) if !s.is_empty() => Some(
1177                    s.split(',')
1178                        .map(|t| t.trim().to_lowercase())
1179                        .filter(|t| !t.is_empty())
1180                        .collect(),
1181                ),
1182                _ => None,
1183            };
1184            let Some(store) = ctx.history_store.clone() else {
1185                return Err(RuntimeError::ToolFailed(
1186                    "memory.history.read: no history store on context".into(),
1187                ));
1188            };
1189            let query = crate::history_store::HistoryQuery {
1190                session_id,
1191                offset,
1192                limit,
1193                role_filter,
1194            };
1195            let page = tokio::task::spawn_blocking(move || store.read(query))
1196                .await
1197                .map_err(|e| RuntimeError::ToolFailed(format!("history.read: {e}")))??;
1198            let item_count = page.items.len();
1199            let items: Vec<Value> = page.items.into_iter().map(Value::Message).collect();
1200            let start = offset;
1201            let end = if item_count == 0 {
1202                start
1203            } else {
1204                start + item_count - 1
1205            };
1206            let header = format!("[history: turns {start}-{end} of {}]", page.total);
1207            Ok(Value::Struct(vec![
1208                ("total".into(), Value::Int(page.total as i64)),
1209                ("offset".into(), Value::Int(page.offset as i64)),
1210                ("limit".into(), Value::Int(page.limit as i64)),
1211                ("header".into(), Value::Str(header)),
1212                ("items".into(), Value::List(items)),
1213            ]))
1214        })
1215    }
1216}
1217
1218pub struct MemoryHistoryCount;
1219
1220impl Tool for MemoryHistoryCount {
1221    fn name(&self) -> &str {
1222        "memory.history.count"
1223    }
1224
1225    fn tier(&self) -> Tier {
1226        Tier::Zero
1227    }
1228
1229    fn description(&self) -> Option<&str> {
1230        Some(
1231            "Return the total message count for a session. Lightweight — use this to check \
1232             how many messages exist before paginating with memory.history.read. \
1233             Params: session_id (string, default current session), role_filter \
1234             (comma-separated: user,assistant,tool,system; default all).",
1235        )
1236    }
1237
1238    fn input_schema(&self) -> serde_json::Value {
1239        serde_json::json!({
1240            "type": "object",
1241            "properties": {
1242                "session_id": {"type": "string"},
1243                "role_filter": {"type": "string"}
1244            }
1245        })
1246    }
1247
1248    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1249        Box::pin(async move {
1250            let Some(current_dir) = ctx.session_dir.as_ref() else {
1251                return Err(RuntimeError::ToolFailed(
1252                    "memory.history.count: no session dir on context".into(),
1253                ));
1254            };
1255            let session_id = match args.named("session_id") {
1256                Some(Value::Str(sid)) if !sid.is_empty() => sid.clone(),
1257                _ => current_dir
1258                    .file_name()
1259                    .map(|n| n.to_string_lossy().into_owned())
1260                    .unwrap_or_default(),
1261            };
1262            let role_filter: Option<Vec<String>> = match args.named("role_filter") {
1263                Some(Value::Str(s)) if !s.is_empty() => Some(
1264                    s.split(',')
1265                        .map(|t| t.trim().to_lowercase())
1266                        .filter(|t| !t.is_empty())
1267                        .collect(),
1268                ),
1269                _ => None,
1270            };
1271            let Some(store) = ctx.history_store.clone() else {
1272                return Err(RuntimeError::ToolFailed(
1273                    "memory.history.count: no history store on context".into(),
1274                ));
1275            };
1276            let total = tokio::task::spawn_blocking(move || {
1277                let role_refs: Option<Vec<&str>> = role_filter
1278                    .as_ref()
1279                    .map(|rs| rs.iter().map(|s| s.as_str()).collect());
1280                store.count(&session_id, role_refs.as_deref())
1281            })
1282            .await
1283            .map_err(|e| RuntimeError::ToolFailed(format!("history.count: {e}")))??;
1284            Ok(Value::Int(total as i64))
1285        })
1286    }
1287}
1288
1289enum HistoryScope {
1290    Session,
1291    Project,
1292}
1293
1294fn required_string(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
1295    match args.named(name) {
1296        Some(Value::Str(s)) => Ok(s.clone()),
1297        Some(other) => Err(RuntimeError::TypeMismatch {
1298            expected: "string".into(),
1299            actual: other.kind_name().into(),
1300        }),
1301        None => Err(RuntimeError::MissingArg(name.into())),
1302    }
1303}