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 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 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}