Skip to main content

rpi_tools/tools/
write.rs

1//! Mirrors `packages/agent/src/harness/tools/write.ts` — the built-in `write`
2//! `AgentTool`. Writes content to a file (creating parent dirs), serialized per
3//! canonical path via the file-mutation-queue, with pre/post abort bracketing.
4//!
5//! Invariant (plan §5.4): abort does NOT unblock the queue — the caller still
6//! waits for its turn, then the closure observes the token and returns
7//! `Err(Aborted)`. The pre/post abort checks around `write_file` mirror the TS
8//! `if (signal?.aborted) throw` lines.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use futures::future::BoxFuture;
14use futures::FutureExt;
15use rpi_agent::agent_tool::AgentTool;
16use rpi_agent::error::AgentError;
17use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
18use rpi_ai::types::{ConstrainedSamplingConfig, ConstrainedStrictness, Tool};
19use schemars::JsonSchema;
20use serde::Deserialize;
21use tokio_util::sync::CancellationToken;
22
23use crate::env::FileContent;
24use crate::error::{FileError, FileErrorCode};
25use crate::file_mutation_queue::{with_file_mutation_queue, MutatingEnv};
26use crate::path_utils::resolve_tool_path;
27use crate::tools::tool_context::ExecutionToolContext;
28
29/// Input for the write tool. Mirrors TS `WriteToolInput`.
30#[derive(Debug, Clone, JsonSchema, Deserialize)]
31pub struct WriteInput {
32    /// Path to the file to write (relative or absolute).
33    pub path: String,
34    /// Content to write to the file.
35    pub content: String,
36}
37
38/// The built-in `write` tool. Holds the mutating env (for the queue + writes).
39pub struct WriteTool {
40    schema: Tool,
41    env: Arc<dyn MutatingEnv>,
42}
43
44impl WriteTool {
45    fn schema() -> Tool {
46        let params = schemars::schema_for!(WriteInput);
47        Tool {
48            name: "write".to_string(),
49            description: "Write content to a file. Creates the file if it doesn't exist, \
50                          overwrites if it does. Automatically creates parent directories."
51                .to_string(),
52            parameters: rpi_ai::types::Schema::new(
53                serde_json::to_value(params).unwrap_or_default(),
54            ),
55            constrained_sampling: Some(ConstrainedSamplingConfig::JsonSchema {
56                strict: ConstrainedStrictness::Prefer,
57            }),
58        }
59    }
60}
61
62/// Build the `write` tool from a context. Requires a mutation-capable env.
63/// Mirrors TS `createWriteTool`.
64pub fn create_write_tool(context: &ExecutionToolContext) -> Arc<dyn AgentTool> {
65    let env = context
66        .mutating_env()
67        .expect("write tool requires a mutation-capable ExecutionToolContext")
68        .clone();
69    Arc::new(WriteTool {
70        schema: WriteTool::schema(),
71        env,
72    })
73}
74
75#[async_trait]
76impl AgentTool for WriteTool {
77    fn schema(&self) -> &Tool {
78        &self.schema
79    }
80    fn label(&self) -> &str {
81        "write"
82    }
83
84    async fn execute(
85        &self,
86        _tool_call_id: &str,
87        params: serde_json::Value,
88        signal: CancellationToken,
89        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
90    ) -> Result<AgentToolResult, AgentError> {
91        let input: WriteInput = serde_json::from_value(params)
92            .map_err(|e| AgentError::Validation(format!("write input invalid: {e}")))?;
93
94        let abs = resolve_tool_path(self.env.as_env(), &input.path, Some(&signal))
95            .await
96            .map_err(file_err_to_agent)?;
97        let abs_str = abs.clone();
98        let path_for_msg = input.path.clone();
99
100        // The mutation-queue closure. Abort does NOT unblock the queue: we still
101        // acquire the lock, then check the token before+after the write.
102        let content = input.content;
103        let env = self.env.clone();
104        // `with_file_mutation_queue` borrows the env for the whole call, so keep
105        // a separate owned clone for the borrow reference and let the closure
106        // capture/move the other.
107        let env_ref = env.clone();
108        let abs_for_closure = abs_str.clone();
109        let result: Result<(), FileError> = with_file_mutation_queue(
110            &*env_ref,
111            &abs_str,
112            &signal,
113            move |cancel: &CancellationToken| -> BoxFuture<'_, Result<(), FileError>> {
114                let env = env.clone();
115                let content = content.clone();
116                let abs_str = abs_for_closure.clone();
117                async move {
118                    check_abort(cancel)?;
119                    env.as_env()
120                        .write_file(&abs_str, FileContent::Text(content), Some(cancel))
121                        .await?;
122                    check_abort(cancel)?;
123                    Ok(())
124                }
125                .boxed()
126            },
127        )
128        .await;
129
130        result.map_err(file_err_to_agent)?;
131
132        Ok(AgentToolResult {
133            content: vec![TextContentOrImage::text(format!(
134                "Successfully wrote to {path_for_msg}"
135            ))],
136            details: serde_json::Value::Null,
137            ..Default::default()
138        })
139    }
140}
141
142/// Check the cancel token, returning `Err(FileError::aborted())` when set.
143/// Mirrors the TS `if (signal?.aborted) throw new Error("Operation aborted")`.
144fn check_abort(cancel: &CancellationToken) -> Result<(), FileError> {
145    if cancel.is_cancelled() {
146        return Err(FileError::new(FileErrorCode::Aborted, "Operation aborted"));
147    }
148    Ok(())
149}
150
151fn file_err_to_agent(e: FileError) -> AgentError {
152    AgentError::Tool(e.to_string())
153}