Skip to main content

microclaw_engine/tools/
fetch_artifact.rs

1use 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
10/// Read a slice of a tool-result artifact created when an oversized tool
11/// response was spilled out of the message history.
12///
13/// Companion to the truncation logic in `tool_executor::maybe_spill_to_artifact`:
14/// when a tool returns more characters than `tool_result_truncation_threshold_chars`,
15/// the head + tail are kept in the message history and the full body is
16/// stashed in `tool_result_artifacts` with a TTL. The agent uses this tool
17/// to read further into the body without paying the token cost on every turn.
18///
19/// Access model: artifacts are scoped to the chat that produced them. The
20/// caller must either own that chat or be a control chat — same gate as
21/// `session_search`.
22pub 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}