microclaw_engine/tools/
fetch_artifact.rs1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4
5use crate::internal::storage::db::Database;
6use microclaw_core::llm_types::ToolDefinition;
7
8use super::{auth_context_from_input, schema_object, Tool, ToolResult};
9
10pub struct FetchArtifactTool {
23 db: Arc<Database>,
24}
25
26impl FetchArtifactTool {
27 pub fn new(db: Arc<Database>) -> Self {
28 Self { db }
29 }
30}
31
32const DEFAULT_LENGTH: usize = 4000;
33const MAX_LENGTH: usize = 16_000;
34
35#[async_trait]
36impl Tool for FetchArtifactTool {
37 fn name(&self) -> &str {
38 "fetch_artifact"
39 }
40
41 fn definition(&self) -> ToolDefinition {
42 ToolDefinition {
43 name: self.name().into(),
44 description: "Read a character range from a tool-result artifact \
45 created when a previous tool response was truncated. The \
46 truncation marker in the inline result tells you the \
47 artifact_id; pass it here with offset/length (in Unicode \
48 code points) to read more. Default length 4000, max 16000."
49 .into(),
50 input_schema: schema_object(
51 json!({
52 "artifact_id": {
53 "type": "string",
54 "description": "Artifact id from the truncation marker (e.g. \"art_…\")."
55 },
56 "offset": {
57 "type": "integer",
58 "description": "Starting character offset (default 0)."
59 },
60 "length": {
61 "type": "integer",
62 "description": "Number of characters to return (default 4000, max 16000)."
63 }
64 }),
65 &["artifact_id"],
66 ),
67 }
68 }
69
70 async fn execute(&self, input: serde_json::Value) -> ToolResult {
71 let artifact_id = match input.get("artifact_id").and_then(|v| v.as_str()) {
72 Some(s) if !s.trim().is_empty() => s.trim().to_string(),
73 _ => {
74 return ToolResult::error("Missing or empty required parameter: artifact_id".into())
75 }
76 };
77 let offset = input
78 .get("offset")
79 .and_then(|v| v.as_i64())
80 .unwrap_or(0)
81 .max(0) as usize;
82 let length = input
83 .get("length")
84 .and_then(|v| v.as_i64())
85 .unwrap_or(DEFAULT_LENGTH as i64)
86 .max(1) as usize;
87 let length = length.min(MAX_LENGTH);
88
89 let Some(auth) = auth_context_from_input(&input) else {
90 return ToolResult::error(
91 "fetch_artifact requires an auth context (caller_chat_id) on the input".into(),
92 );
93 };
94
95 let db = self.db.clone();
96 let artifact_id_for_db = artifact_id.clone();
97 let now = chrono::Utc::now().to_rfc3339();
98 let lookup = crate::internal::storage::db::call_blocking(db, move |db| {
99 db.get_tool_artifact_slice(&artifact_id_for_db, offset, length, &now)
100 })
101 .await;
102
103 match lookup {
104 Ok(Some((meta, slice))) => {
105 if meta.chat_id != auth.caller_chat_id && !auth.is_control_chat() {
106 return ToolResult::error(format!(
107 "Permission denied: artifact `{artifact_id}` belongs to a different chat."
108 ))
109 .with_error_type("forbidden");
110 }
111 let returned: usize = slice.chars().count();
112 let total = meta.total_chars as usize;
113 let next_offset = offset + returned;
114 let has_more = next_offset < total;
115 let header = format!(
116 "artifact_id: {}\ntool: {}\ntotal_chars: {}\noffset: {}\nreturned: {}\nhas_more: {}",
117 meta.artifact_id, meta.tool_name, total, offset, returned, has_more
118 );
119 let body = if returned == 0 {
120 "(no content in this range)".to_string()
121 } else {
122 slice
123 };
124 ToolResult::success(format!("{header}\n---\n{body}"))
125 }
126 Ok(None) => {
127 ToolResult::error(format!("Artifact `{artifact_id}` not found or expired."))
128 .with_error_type("artifact_missing")
129 }
130 Err(e) => ToolResult::error(format!("Failed to read artifact: {e}")),
131 }
132 }
133}