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 content_len = input.content.len();
99        let path_for_msg = input.path.clone();
100
101        // The mutation-queue closure. Abort does NOT unblock the queue: we still
102        // acquire the lock, then check the token before+after the write.
103        let content = input.content;
104        let env = self.env.clone();
105        // `with_file_mutation_queue` borrows the env for the whole call, so keep
106        // a separate owned clone for the borrow reference and let the closure
107        // capture/move the other.
108        let env_ref = env.clone();
109        let abs_for_closure = abs_str.clone();
110        let result: Result<(), FileError> = with_file_mutation_queue(
111            &*env_ref,
112            &abs_str,
113            &signal,
114            move |cancel: &CancellationToken| -> BoxFuture<'_, Result<(), FileError>> {
115                let env = env.clone();
116                let content = content.clone();
117                let abs_str = abs_for_closure.clone();
118                async move {
119                    check_abort(cancel)?;
120                    env.as_env()
121                        .write_file(&abs_str, FileContent::Text(content), Some(cancel))
122                        .await?;
123                    check_abort(cancel)?;
124                    Ok(())
125                }
126                .boxed()
127            },
128        )
129        .await;
130
131        result.map_err(file_err_to_agent)?;
132
133        Ok(AgentToolResult {
134            content: vec![TextContentOrImage::text(format!(
135                "Successfully wrote {content_len} bytes to {path_for_msg}"
136            ))],
137            details: serde_json::Value::Null,
138            ..Default::default()
139        })
140    }
141}
142
143/// Check the cancel token, returning `Err(FileError::aborted())` when set.
144/// Mirrors the TS `if (signal?.aborted) throw new Error("Operation aborted")`.
145fn check_abort(cancel: &CancellationToken) -> Result<(), FileError> {
146    if cancel.is_cancelled() {
147        return Err(FileError::new(FileErrorCode::Aborted, "Operation aborted"));
148    }
149    Ok(())
150}
151
152fn file_err_to_agent(e: FileError) -> AgentError {
153    AgentError::Tool(e.to_string())
154}