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