1use async_trait::async_trait;
2use serde_json::json;
3
4use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
5use bamboo_agent_core::Session;
6use bamboo_memory::memory_store::{
7 DurableMemoryStatus, MemoryQueryOptions, MemoryScope, MemoryStore, MAX_MAX_CHARS,
8 MAX_QUERY_LIMIT,
9};
10use bamboo_tools::tools::session_memory::{
11 execute_session_memory_action, SessionMemoryAction, MEMORY_SESSION_ACTION_NAMES,
12};
13
14mod args;
15mod parsing;
16
17#[cfg(test)]
18mod tests;
19
20use args::MemoryArgs;
21
22#[derive(Clone)]
23pub struct MemoryTool {
24 session_repo: bamboo_engine::SessionRepository,
25 memory_store: MemoryStore,
26}
27
28impl MemoryTool {
29 pub fn new(
30 session_repo: bamboo_engine::SessionRepository,
31 data_dir: impl Into<std::path::PathBuf>,
32 ) -> Self {
33 Self {
34 session_repo,
35 memory_store: MemoryStore::new(data_dir),
36 }
37 }
38
39 async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
40 self.session_repo.load(session_id?).await
41 }
42
43 async fn resolve_project_key(
44 &self,
45 explicit: Option<&str>,
46 session_id: Option<&str>,
47 ) -> Option<String> {
48 if let Some(explicit) = explicit
49 .map(str::trim)
50 .filter(|value| !value.is_empty())
51 .map(ToString::to_string)
52 {
53 return Some(explicit);
54 }
55
56 if let Some(project_key) = self.memory_store.project_key_for_session(session_id) {
57 return Some(project_key);
58 }
59
60 self.session_for_context(session_id)
61 .await
62 .and_then(|session| session.metadata.get("workspace_path").cloned())
63 .map(std::path::PathBuf::from)
64 .map(|path| bamboo_memory::memory_store::project_key_from_path(&path))
65 }
66}
67
68#[async_trait]
69impl Tool for MemoryTool {
70 fn name(&self) -> &str {
71 "memory"
72 }
73
74 fn description(&self) -> &str {
75 "Unified memory management tool for Bamboo. Use session_* actions for session continuity notes, and query/get/write/merge/split/consolidate/purge/inspect/rebuild for durable project/global memory backed by canonical topic files and derived indexes."
76 }
77
78 fn parameters_schema(&self) -> serde_json::Value {
79 json!({
80 "type": "object",
81 "properties": {
82 "action": {
83 "type": "string",
84 "enum": [
85 "session_read",
86 "session_append",
87 "session_replace",
88 "session_clear",
89 "session_list_topics",
90 "query",
91 "get",
92 "find_duplicates",
93 "write",
94 "merge",
95 "split",
96 "consolidate",
97 "purge",
98 "inspect",
99 "rebuild",
100 "scan_blobs",
101 "scan_duplicates"
102 ]
103 },
104 "scope": {"type": "string", "enum": ["session", "project", "global"]},
105 "granularity": {
106 "type": "string",
107 "enum": ["day", "week", "month", "quarter", "year"],
108 "description": "Optional temporal granularity for `write`, orthogonal to scope: day (today's working context), week (sprint), month, quarter (direction), year (long-term goals). Omit if the memory has no time horizon. Coarser granularities are prefix-cache friendly and recalled ahead of finer ones at equal relevance."
109 },
110 "project_key": {"type": "string"},
111 "topic": {"type": "string"},
112 "id": {"type": "string"},
113 "query": {"type": "string"},
114 "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
115 "title": {"type": "string"},
116 "content": {"type": "string"},
117 "tags": {"type": "array", "items": {"type": "string"}},
118 "pieces": {"type": "array", "items": {"type": "object"}},
119 "ids": {"type": "array", "items": {"type": "string"}},
120 "min_score": {"type": "number"},
121 "filters": {"type": "object"},
122 "options": {"type": "object"},
123 "reason": {"type": "string"}
124 },
125 "required": ["action"]
126 })
127 }
128
129 fn call_mutability(&self, args: &serde_json::Value) -> bamboo_tools::ToolMutability {
130 let action = args
131 .get("action")
132 .and_then(|value| value.as_str())
133 .unwrap_or("")
134 .trim()
135 .to_ascii_lowercase();
136 match action.as_str() {
137 "session_read"
138 | "session_list_topics"
139 | "query"
140 | "get"
141 | "find_duplicates"
142 | "scan_blobs"
143 | "scan_duplicates"
144 | "inspect" => bamboo_tools::ToolMutability::ReadOnly,
145 _ => bamboo_tools::ToolMutability::Mutating,
146 }
147 }
148
149 fn call_concurrency_safe(&self, args: &serde_json::Value) -> bool {
150 matches!(
151 self.call_mutability(args),
152 bamboo_tools::ToolMutability::ReadOnly
153 )
154 }
155
156 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
157 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
158 .await
159 }
160
161 async fn execute_with_context(
162 &self,
163 args: serde_json::Value,
164 ctx: ToolExecutionContext<'_>,
165 ) -> Result<ToolResult, ToolError> {
166 let session_id = ctx.session_id.ok_or_else(|| {
167 ToolError::Execution("memory requires a session_id in tool context".to_string())
168 })?;
169
170 let parsed: MemoryArgs = serde_json::from_value(args).map_err(|error| {
171 ToolError::InvalidArguments(format!("Invalid memory args: {error}"))
172 })?;
173
174 match parsed {
175 MemoryArgs::SessionRead { topic, options } => {
176 let max_chars = options.and_then(|value| value.max_chars);
177 execute_session_memory_action(
178 &self.memory_store,
179 session_id,
180 SessionMemoryAction::Read,
181 topic.as_deref(),
182 None,
183 max_chars,
184 MEMORY_SESSION_ACTION_NAMES,
185 )
186 .await
187 }
188 MemoryArgs::SessionAppend { topic, content } => {
189 execute_session_memory_action(
190 &self.memory_store,
191 session_id,
192 SessionMemoryAction::Append,
193 topic.as_deref(),
194 Some(content.as_str()),
195 None,
196 MEMORY_SESSION_ACTION_NAMES,
197 )
198 .await
199 }
200 MemoryArgs::SessionReplace { topic, content } => {
201 execute_session_memory_action(
202 &self.memory_store,
203 session_id,
204 SessionMemoryAction::Replace,
205 topic.as_deref(),
206 Some(content.as_str()),
207 None,
208 MEMORY_SESSION_ACTION_NAMES,
209 )
210 .await
211 }
212 MemoryArgs::SessionClear { topic } => {
213 execute_session_memory_action(
214 &self.memory_store,
215 session_id,
216 SessionMemoryAction::Clear,
217 topic.as_deref(),
218 None,
219 None,
220 MEMORY_SESSION_ACTION_NAMES,
221 )
222 .await
223 }
224 MemoryArgs::SessionListTopics => {
225 execute_session_memory_action(
226 &self.memory_store,
227 session_id,
228 SessionMemoryAction::ListTopics,
229 None,
230 None,
231 None,
232 MEMORY_SESSION_ACTION_NAMES,
233 )
234 .await
235 }
236 MemoryArgs::Query {
237 scope,
238 query,
239 filters,
240 project_key,
241 options,
242 } => {
243 let scope = Self::parse_scope(Some(&scope))?;
244 if scope == MemoryScope::Session {
245 return Err(ToolError::InvalidArguments(
246 "query supports durable scopes only; use session_read/session_list_topics for session scope"
247 .to_string(),
248 ));
249 }
250 let project_key = self
251 .resolve_project_key(project_key.as_deref(), Some(session_id))
252 .await;
253 let options = MemoryQueryOptions {
254 limit: options
255 .as_ref()
256 .and_then(|value| value.limit)
257 .map(|value| value.min(MAX_QUERY_LIMIT)),
258 max_chars: options
259 .as_ref()
260 .and_then(|value| value.max_chars)
261 .map(|value| value.min(MAX_MAX_CHARS)),
262 cursor: options.as_ref().and_then(|value| value.cursor.clone()),
263 include_related: options
264 .as_ref()
265 .and_then(|value| value.include_related)
266 .unwrap_or(false),
267 };
268 let (filter_types, filter_statuses) = Self::parse_query_filters(filters.as_ref())?;
269 let result = self
270 .memory_store
271 .query_scope(
272 scope,
273 project_key.as_deref(),
274 query.as_deref(),
275 filter_types.as_ref(),
276 filter_statuses.as_ref(),
277 &options,
278 )
279 .await
280 .map_err(|error| {
281 ToolError::Execution(format!("Failed to query memory: {error}"))
282 })?;
283 Ok(ToolResult {
284 success: true,
285 result: json!({
286 "action": "query",
287 "success": true,
288 "data": result,
289 "summary": bamboo_memory::memory_store::summary_json(result.returned_count, result.matched_count),
290 "warnings": [],
291 }).to_string(),
292 display_preference: Some("json".to_string()),
293 images: Vec::new(),
294 })
295 }
296 MemoryArgs::Get {
297 id,
298 project_key,
299 options,
300 } => {
301 let project_key = self
302 .resolve_project_key(project_key.as_deref(), Some(session_id))
303 .await;
304 let max_chars = options
305 .and_then(|value| value.max_chars)
306 .unwrap_or(MAX_MAX_CHARS)
307 .min(MAX_MAX_CHARS);
308 let Some(mut doc) = self
309 .memory_store
310 .get_memory(id.trim(), project_key.as_deref())
311 .await
312 .map_err(|error| {
313 ToolError::Execution(format!("Failed to get memory: {error}"))
314 })?
315 else {
316 return Err(ToolError::Execution(format!(
317 "memory not found: {}",
318 id.trim()
319 )));
320 };
321 let (body, truncated) =
322 bamboo_memory::memory_store::truncate_chars(&doc.body, max_chars);
323 doc.body = body;
324 Ok(ToolResult {
325 success: true,
326 result: json!({
327 "action": "get",
328 "id": doc.frontmatter.id,
329 "memory": {
330 "frontmatter": doc.frontmatter,
331 "body": doc.body,
332 "path": doc.path,
333 "body_truncated": truncated,
334 }
335 })
336 .to_string(),
337 display_preference: Some("json".to_string()),
338 images: Vec::new(),
339 })
340 }
341 MemoryArgs::Write {
342 scope,
343 r#type,
344 title,
345 content,
346 tags,
347 project_key,
348 granularity,
349 options,
350 } => {
351 let scope = Self::parse_scope(Some(&scope))?;
352 if scope == MemoryScope::Session {
353 return Err(ToolError::InvalidArguments(
354 "write supports durable scopes only; use session_replace/session_append for session scope"
355 .to_string(),
356 ));
357 }
358 let granularity = Self::parse_granularity(granularity.as_deref())?;
359 let project_key = self
360 .resolve_project_key(project_key.as_deref(), Some(session_id))
361 .await;
362 let doc = self
363 .memory_store
364 .write_memory(
365 scope,
366 project_key.as_deref(),
367 Self::parse_type(&r#type)?,
368 &title,
369 &content,
370 &tags,
371 Some(session_id),
372 "main-model",
373 options
374 .and_then(|value| value.allow_merge_if_similar)
375 .unwrap_or(false),
376 granularity,
377 )
378 .await
379 .map_err(|error| {
380 ToolError::Execution(format!("Failed to write memory: {error}"))
381 })?;
382 Ok(ToolResult {
383 success: true,
384 result: json!({
385 "action": "write",
386 "memory": {
387 "id": doc.frontmatter.id,
388 "title": doc.frontmatter.title,
389 "type": doc.frontmatter.r#type,
390 "scope": doc.frontmatter.scope,
391 "status": doc.frontmatter.status,
392 "project_key": doc.frontmatter.project_key,
393 "path": doc.path,
394 }
395 })
396 .to_string(),
397 display_preference: Some("json".to_string()),
398 images: Vec::new(),
399 })
400 }
401 MemoryArgs::Merge {
402 id,
403 content,
404 tags,
405 project_key,
406 source_memory_ids,
407 mode,
408 reason,
409 } => {
410 let project_key = self
411 .resolve_project_key(project_key.as_deref(), Some(session_id))
412 .await;
413 let mode = Self::parse_merge_mode(mode.as_deref())?;
414 if matches!(mode.as_deref(), Some("contradict")) {
415 let Some(result) = self
416 .memory_store
417 .mark_memory_contradicted(
418 id.trim(),
419 project_key.as_deref(),
420 &source_memory_ids,
421 reason.as_deref().or(Some(content.trim())),
422 Some(session_id),
423 "main-model",
424 )
425 .await
426 .map_err(|error| {
427 ToolError::Execution(format!("Failed to contradict memory: {error}"))
428 })?
429 else {
430 return Err(ToolError::Execution(format!(
431 "memory not found: {}",
432 id.trim()
433 )));
434 };
435 Ok(ToolResult {
436 success: true,
437 result: json!({
438 "action": "merge",
439 "mode": "contradict",
440 "data": result,
441 })
442 .to_string(),
443 display_preference: Some("json".to_string()),
444 images: Vec::new(),
445 })
446 } else {
447 let Some(result) = self
448 .memory_store
449 .merge_memory(
450 id.trim(),
451 project_key.as_deref(),
452 &content,
453 &tags,
454 Some(session_id),
455 "main-model",
456 &source_memory_ids,
457 )
458 .await
459 .map_err(|error| {
460 ToolError::Execution(format!("Failed to merge memory: {error}"))
461 })?
462 else {
463 return Err(ToolError::Execution(format!(
464 "memory not found: {}",
465 id.trim()
466 )));
467 };
468 Ok(ToolResult {
469 success: true,
470 result: json!({
471 "action": "merge",
472 "mode": mode.unwrap_or_else(|| "merge".to_string()),
473 "data": result,
474 })
475 .to_string(),
476 display_preference: Some("json".to_string()),
477 images: Vec::new(),
478 })
479 }
480 }
481 MemoryArgs::FindDuplicates {
482 scope,
483 title,
484 content,
485 r#type,
486 tags,
487 project_key,
488 options,
489 } => {
490 let scope = Self::parse_scope(Some(&scope))?;
491 if scope == MemoryScope::Session {
492 return Err(ToolError::InvalidArguments(
493 "find_duplicates supports durable scopes only".to_string(),
494 ));
495 }
496 let r#type = match r#type.as_deref() {
497 Some(value) => Some(Self::parse_type(value)?),
498 None => None,
499 };
500 let project_key = self
501 .resolve_project_key(project_key.as_deref(), Some(session_id))
502 .await;
503 let limit = options
504 .and_then(|value| value.limit)
505 .unwrap_or(5)
506 .clamp(1, MAX_QUERY_LIMIT);
507 let candidates = self
508 .memory_store
509 .find_duplicate_candidates(
510 scope,
511 project_key.as_deref(),
512 r#type,
513 &title,
514 content.as_deref().unwrap_or(""),
515 &tags,
516 limit,
517 )
518 .await
519 .map_err(|error| {
520 ToolError::Execution(format!("Failed to find duplicates: {error}"))
521 })?;
522 Ok(ToolResult {
523 success: true,
524 result: json!({
525 "action": "find_duplicates",
526 "candidates": candidates,
527 })
528 .to_string(),
529 display_preference: Some("json".to_string()),
530 images: Vec::new(),
531 })
532 }
533 MemoryArgs::Split {
534 id,
535 project_key,
536 pieces,
537 } => {
538 if pieces.is_empty() {
539 return Err(ToolError::InvalidArguments(
540 "split requires at least one piece".to_string(),
541 ));
542 }
543 let project_key = self
544 .resolve_project_key(project_key.as_deref(), Some(session_id))
545 .await;
546 let mut split_pieces = Vec::with_capacity(pieces.len());
547 for piece in pieces {
548 let r#type = match piece.r#type.as_deref() {
549 Some(value) => Some(Self::parse_type(value)?),
550 None => None,
551 };
552 split_pieces.push(bamboo_memory::memory_store::MemorySplitPiece {
553 title: piece.title,
554 r#type,
555 content: piece.content,
556 tags: piece.tags,
557 });
558 }
559 let Some(result) = self
560 .memory_store
561 .split_memory(
562 id.trim(),
563 project_key.as_deref(),
564 &split_pieces,
565 Some(session_id),
566 "main-model",
567 )
568 .await
569 .map_err(|error| {
570 ToolError::Execution(format!("Failed to split memory: {error}"))
571 })?
572 else {
573 return Err(ToolError::Execution(format!(
574 "memory not found: {}",
575 id.trim()
576 )));
577 };
578 Ok(ToolResult {
579 success: true,
580 result: json!({
581 "action": "split",
582 "data": result,
583 })
584 .to_string(),
585 display_preference: Some("json".to_string()),
586 images: Vec::new(),
587 })
588 }
589 MemoryArgs::ScanBlobs {
590 scope,
591 project_key,
592 min_sections,
593 options,
594 } => {
595 let scope = Self::parse_scope(Some(&scope))?;
596 if scope == MemoryScope::Session {
597 return Err(ToolError::InvalidArguments(
598 "scan_blobs supports durable scopes only".to_string(),
599 ));
600 }
601 let project_key = self
602 .resolve_project_key(project_key.as_deref(), Some(session_id))
603 .await;
604 let min_sections = min_sections.unwrap_or(3);
605 let limit = options
606 .and_then(|value| value.limit)
607 .unwrap_or(20)
608 .clamp(1, 200);
609 let report = self
610 .memory_store
611 .scan_blob_candidates(scope, project_key.as_deref(), min_sections, limit)
612 .await
613 .map_err(|error| {
614 ToolError::Execution(format!("Failed to scan blobs: {error}"))
615 })?;
616 Ok(ToolResult {
617 success: true,
618 result: json!({
619 "action": "scan_blobs",
620 "report": report,
621 })
622 .to_string(),
623 display_preference: Some("json".to_string()),
624 images: Vec::new(),
625 })
626 }
627 MemoryArgs::ScanDuplicates {
628 scope,
629 project_key,
630 min_score,
631 options,
632 } => {
633 let scope = Self::parse_scope(Some(&scope))?;
634 if scope == MemoryScope::Session {
635 return Err(ToolError::InvalidArguments(
636 "scan_duplicates supports durable scopes only".to_string(),
637 ));
638 }
639 let project_key = self
640 .resolve_project_key(project_key.as_deref(), Some(session_id))
641 .await;
642 let min_score = min_score.unwrap_or(0.6);
643 let limit = options
644 .and_then(|value| value.limit)
645 .unwrap_or(20)
646 .clamp(1, 200);
647 let report = self
648 .memory_store
649 .scan_duplicate_clusters(scope, project_key.as_deref(), min_score, 5, limit)
650 .await
651 .map_err(|error| {
652 ToolError::Execution(format!("Failed to scan duplicates: {error}"))
653 })?;
654 Ok(ToolResult {
655 success: true,
656 result: json!({
657 "action": "scan_duplicates",
658 "report": report,
659 })
660 .to_string(),
661 display_preference: Some("json".to_string()),
662 images: Vec::new(),
663 })
664 }
665 MemoryArgs::Consolidate {
666 ids,
667 title,
668 content,
669 r#type,
670 tags,
671 project_key,
672 } => {
673 if ids.len() < 2 {
674 return Err(ToolError::InvalidArguments(
675 "consolidate requires at least two source memory ids".to_string(),
676 ));
677 }
678 let r#type = match r#type.as_deref() {
679 Some(value) => Some(Self::parse_type(value)?),
680 None => None,
681 };
682 let project_key = self
683 .resolve_project_key(project_key.as_deref(), Some(session_id))
684 .await;
685 let merged = bamboo_memory::memory_store::MemorySplitPiece {
686 title,
687 r#type,
688 content,
689 tags,
690 };
691 let ids: Vec<String> = ids.iter().map(|id| id.trim().to_string()).collect();
692 let Some(result) = self
693 .memory_store
694 .consolidate_memories(
695 &ids,
696 project_key.as_deref(),
697 &merged,
698 Some(session_id),
699 "main-model",
700 )
701 .await
702 .map_err(|error| {
703 ToolError::Execution(format!("Failed to consolidate memories: {error}"))
704 })?
705 else {
706 return Err(ToolError::Execution(
707 "one or more source memories not found".to_string(),
708 ));
709 };
710 Ok(ToolResult {
711 success: true,
712 result: json!({
713 "action": "consolidate",
714 "data": result,
715 })
716 .to_string(),
717 display_preference: Some("json".to_string()),
718 images: Vec::new(),
719 })
720 }
721 MemoryArgs::Purge {
722 id,
723 scope,
724 reason,
725 project_key,
726 filters,
727 mode,
728 } => {
729 let mode = match mode
730 .as_deref()
731 .map(str::trim)
732 .filter(|value| !value.is_empty())
733 {
734 Some(value) => Self::parse_status(value)?,
735 None => DurableMemoryStatus::Archived,
736 };
737 let project_key = self
738 .resolve_project_key(project_key.as_deref(), Some(session_id))
739 .await;
740
741 if let Some(id) = id
742 .as_deref()
743 .map(str::trim)
744 .filter(|value| !value.is_empty())
745 {
746 let Some(doc) = self
747 .memory_store
748 .archive_memory(id, project_key.as_deref(), mode, reason.as_deref())
749 .await
750 .map_err(|error| {
751 ToolError::Execution(format!("Failed to purge memory: {error}"))
752 })?
753 else {
754 return Err(ToolError::Execution(format!("memory not found: {}", id)));
755 };
756 Ok(ToolResult {
757 success: true,
758 result: json!({
759 "action": "purge",
760 "id": doc.frontmatter.id,
761 "status": doc.frontmatter.status,
762 })
763 .to_string(),
764 display_preference: Some("json".to_string()),
765 images: Vec::new(),
766 })
767 } else {
768 let scope = Self::parse_scope(scope.as_deref())?;
769 if scope == MemoryScope::Session {
770 return Err(ToolError::InvalidArguments(
771 "purge supports durable scopes only in v1".to_string(),
772 ));
773 }
774 let (filter_types, filter_statuses) =
775 Self::parse_query_filters(filters.as_ref())?;
776 let result = self
777 .memory_store
778 .purge_memories(
779 scope,
780 project_key.as_deref(),
781 filter_types.as_ref(),
782 filter_statuses.as_ref(),
783 mode,
784 reason.as_deref(),
785 )
786 .await
787 .map_err(|error| {
788 ToolError::Execution(format!("Failed to purge memory: {error}"))
789 })?;
790 Ok(ToolResult {
791 success: true,
792 result: json!({
793 "action": "purge",
794 "data": result,
795 })
796 .to_string(),
797 display_preference: Some("json".to_string()),
798 images: Vec::new(),
799 })
800 }
801 }
802 MemoryArgs::Inspect { scope, project_key } => {
803 let scope = Self::parse_scope(Some(&scope))?;
804 if scope == MemoryScope::Session {
805 return Err(ToolError::InvalidArguments(
806 "inspect supports durable scopes only in v1".to_string(),
807 ));
808 }
809 let project_key = self
810 .resolve_project_key(project_key.as_deref(), Some(session_id))
811 .await;
812 let result = self
813 .memory_store
814 .inspect_scope(scope, project_key.as_deref())
815 .await
816 .map_err(|error| {
817 ToolError::Execution(format!("Failed to inspect memory: {error}"))
818 })?;
819 Ok(ToolResult {
820 success: true,
821 result: json!({
822 "action": "inspect",
823 "data": result,
824 })
825 .to_string(),
826 display_preference: Some("json".to_string()),
827 images: Vec::new(),
828 })
829 }
830 MemoryArgs::Rebuild { scope, project_key } => {
831 let scope = Self::parse_scope(Some(&scope))?;
832 if scope == MemoryScope::Session {
833 return Err(ToolError::InvalidArguments(
834 "rebuild supports durable scopes only in v1".to_string(),
835 ));
836 }
837 let project_key = self
838 .resolve_project_key(project_key.as_deref(), Some(session_id))
839 .await;
840 self.memory_store
841 .rebuild_scope(scope, project_key.as_deref())
842 .await
843 .map_err(|error| {
844 ToolError::Execution(format!("Failed to rebuild memory artifacts: {error}"))
845 })?;
846 let inspect = self
847 .memory_store
848 .inspect_scope(scope, project_key.as_deref())
849 .await
850 .map_err(|error| {
851 ToolError::Execution(format!("Failed to inspect rebuilt memory: {error}"))
852 })?;
853 Ok(ToolResult {
854 success: true,
855 result: json!({
856 "action": "rebuild",
857 "scope": scope,
858 "project_key": project_key,
859 "data": inspect,
860 })
861 .to_string(),
862 display_preference: Some("json".to_string()),
863 images: Vec::new(),
864 })
865 }
866 }
867 }
868}