mobius 0.15.47

A small, modular Rust framework for building coding agents
Documentation
use diffy::{DiffOptions, Patch};
use serde::Deserialize;
use serde_json::Value;

use super::patch::{apply_patch_document, parse_patch_document};
use super::{
    ApprovalRequirement, ExecutionMode, HookIdentity, MAX_MUTATION_BYTES, MAX_TOOL_OUTPUT_BYTES,
    Tool, ToolContext, ToolExposure,
};
use crate::backend::model::ToolDefinition;
use crate::backend::session_files::SessionFileStore;
use crate::protocol::{
    ContentPart, EventMsg, FrontendBlock, FrontendBlockFormat, FrontendBlockUpdate, ImageDetail,
    ToolContent, ToolResponse,
};
use crate::{BoxFuture, Error, Result};

#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Definition {
    read_file: super::ToolSpec,
    view_image: super::ToolSpec,
    write_file: super::ToolSpec,
    apply_patch: super::ToolSpec,
}
static DEFINITION: std::sync::LazyLock<Definition> = std::sync::LazyLock::new(|| {
    toml::from_str(include_str!("coding.toml")).expect("bundled coding tools must be valid")
});

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PathArgs {
    path: String,
}

pub(super) struct ReadFile;

impl Tool for ReadFile {
    fn definition(&self) -> ToolDefinition {
        DEFINITION.read_file.tool.clone()
    }

    fn render(&self, event: &crate::protocol::EventMsg) -> Option<crate::protocol::FrontendBlock> {
        DEFINITION.read_file.render(event)
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    fn execution_mode(&self) -> ExecutionMode {
        ExecutionMode::Parallel
    }

    fn call<'a>(
        &'a self,
        context: ToolContext,
        arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        Box::pin(async move {
            let arguments: PathArgs = serde_json::from_value(arguments)?;
            context
                .sandbox
                .read(&arguments.path, &context.permissions)
                .await
                .map(Into::into)
        })
    }
}

pub(super) struct ViewImage {
    pub(super) store: SessionFileStore,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ViewImageArgs {
    images: Vec<ImageSource>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ImageSource {
    path: Option<String>,
    file_id: Option<String>,
    #[serde(default)]
    detail: ImageDetail,
}

impl Tool for ViewImage {
    fn definition(&self) -> ToolDefinition {
        DEFINITION.view_image.tool.clone()
    }

    fn tool_exposure(&self, context: &mut super::ToolExposureContext<'_>) {
        if !context.supports_tool_image_input() {
            context.hide(&[DEFINITION.view_image.tool.name.as_str()]);
        }
    }

    fn render(&self, event: &EventMsg) -> Option<FrontendBlock> {
        super::render_tool_event(
            event,
            |name| name == DEFINITION.view_image.tool.name,
            |_, arguments| {
                let detail = arguments
                    .get("images")
                    .and_then(Value::as_array)
                    .into_iter()
                    .flatten()
                    .filter_map(|image| {
                        image
                            .get("path")
                            .or_else(|| image.get("file_id"))
                            .and_then(Value::as_str)
                    })
                    .collect::<Vec<_>>()
                    .join("\n");
                super::ToolHeading {
                    title: DEFINITION.view_image.title.clone(),
                    detail,
                }
            },
        )
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    fn execution_mode(&self) -> ExecutionMode {
        ExecutionMode::Parallel
    }

    fn call<'a>(
        &'a self,
        context: ToolContext,
        arguments: Value,
    ) -> BoxFuture<'a, Result<ToolResponse>> {
        Box::pin(async move {
            let arguments: ViewImageArgs = serde_json::from_value(arguments)?;
            if arguments.images.is_empty() || arguments.images.len() > 16 {
                return Err(Error::Tool("view_image requires 1–16 images".into()));
            }
            let mut content = Vec::new();
            for source in arguments.images {
                let image = match (source.path, source.file_id) {
                    (Some(path), None) => {
                        let bytes = context
                            .sandbox
                            .read_bytes(
                                &path,
                                crate::backend::sandbox::MAX_BINARY_FILE_BYTES,
                                &context.permissions,
                            )
                            .await?;
                        let name = std::path::Path::new(&path)
                            .file_name()
                            .and_then(|name| name.to_str())
                            .ok_or_else(|| Error::Tool("image path must name a file".into()))?;
                        self.store
                            .ingest_image(
                                context.permissions.session_id(),
                                name.into(),
                                bytes,
                                source.detail,
                            )
                            .await?
                    }
                    (None, Some(file_id)) => {
                        let file = self
                            .store
                            .file_reference(context.permissions.session_id(), &file_id)
                            .await?;
                        self.store
                            .inspect_image(context.permissions.session_id(), &file, source.detail)
                            .await?
                    }
                    _ => {
                        return Err(Error::Tool(
                            "each image requires exactly one of path or file_id".into(),
                        ));
                    }
                };
                content.push(ContentPart::Text {
                    text: format!(
                        "{}: {} × {} pixels; file_id={}",
                        image.file.name, image.width, image.height, image.file.id
                    ),
                });
                content.push(ContentPart::Image { image });
            }
            Ok(ToolResponse {
                content: ToolContent(content),
                is_error: false,
            })
        })
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WriteArgs {
    path: String,
    content: String,
}

pub(super) struct WriteFile;

impl Tool for WriteFile {
    fn definition(&self) -> ToolDefinition {
        DEFINITION.write_file.tool.clone()
    }

    fn render(&self, event: &crate::protocol::EventMsg) -> Option<crate::protocol::FrontendBlock> {
        DEFINITION.write_file.render(event)
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    fn approval(&self) -> ApprovalRequirement {
        ApprovalRequirement::Always
    }

    fn call<'a>(
        &'a self,
        context: ToolContext,
        arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        Box::pin(async move {
            let arguments: WriteArgs = serde_json::from_value(arguments)?;
            if arguments.content.len() > MAX_MUTATION_BYTES {
                return Err(Error::Tool(format!(
                    "content exceeds {MAX_MUTATION_BYTES} bytes"
                )));
            }
            context
                .sandbox
                .write(&arguments.path, &arguments.content, &context.permissions)
                .await?;
            Ok((format!(
                "wrote {} bytes to {}",
                arguments.content.len(),
                arguments.path
            ))
            .into())
        })
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ApplyPatchArgs {
    patch: String,
}

pub(super) struct ApplyPatch;

impl Tool for ApplyPatch {
    fn definition(&self) -> ToolDefinition {
        DEFINITION.apply_patch.tool.clone()
    }

    fn prompt_section(&self) -> Option<&str> {
        DEFINITION.apply_patch.prompt.as_deref()
    }

    fn render(&self, event: &EventMsg) -> Option<FrontendBlock> {
        let mut block = super::render_tool_event(
            event,
            |name| name == DEFINITION.apply_patch.tool.name,
            |_, arguments| {
                let detail = arguments
                    .get("patch")
                    .and_then(Value::as_str)
                    .and_then(|patch| {
                        patch
                            .lines()
                            .find_map(|line| line.strip_prefix("*** Update File: "))
                    })
                    .unwrap_or_default()
                    .into();
                super::ToolHeading {
                    title: DEFINITION.apply_patch.title.clone(),
                    detail,
                }
            },
        )?;
        if let EventMsg::ToolCallEnd(result) = event
            && !result.is_error
            && Patch::from_str(&result.output.text()).is_ok()
        {
            block.update = FrontendBlockUpdate::Replace;
            block.title = DEFINITION.apply_patch.title.clone();
            block.text = result.output.text();
            block.format = FrontendBlockFormat::UnifiedDiff;
        }
        Some(block)
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    fn approval(&self) -> ApprovalRequirement {
        ApprovalRequirement::Always
    }

    fn hook_identity(&self) -> Option<HookIdentity> {
        Some(HookIdentity {
            name: "apply_patch",
            subjects: &["apply_patch", "Edit", "Write"],
        })
    }

    fn hook_input(&self, arguments: &Value) -> Value {
        serde_json::json!({
            "command": arguments.get("patch").cloned().unwrap_or(Value::Null)
        })
    }

    fn rewrite_hook_input(&self, input: Value) -> Result<Value> {
        let command = input
            .get("command")
            .and_then(Value::as_str)
            .ok_or_else(|| Error::Config("hook tool rewrite requires `command`".into()))?;
        Ok(serde_json::json!({"patch": command}))
    }

    fn call<'a>(
        &'a self,
        context: ToolContext,
        arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        Box::pin(async move {
            let arguments: ApplyPatchArgs = serde_json::from_value(arguments)?;
            if arguments.patch.len() > MAX_MUTATION_BYTES {
                return Err(Error::Tool(format!(
                    "patch exceeds {MAX_MUTATION_BYTES} bytes"
                )));
            }
            let document = parse_patch_document(&arguments.patch)?;
            let content = context
                .sandbox
                .read(&document.path, &context.permissions)
                .await?;
            let updated = apply_patch_document(&content, &document)?;
            if updated == content {
                return Err(Error::Tool(
                    "patch rejected: patch applies but makes no changes".into(),
                ));
            }
            let mut options = DiffOptions::new();
            options
                .set_original_filename(document.path.clone())
                .set_modified_filename(document.path.clone());
            let diff = options.create_patch(&content, &updated).to_string();
            context
                .sandbox
                .write(&document.path, &updated, &context.permissions)
                .await?;
            Ok((if diff.len() <= MAX_TOOL_OUTPUT_BYTES {
                diff
            } else {
                format!("patched {} (diff too large to display)", document.path)
            })
            .into())
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn file_arguments_reject_unknown_fields() {
        assert!(
            serde_json::from_value::<PathArgs>(
                serde_json::json!({"path": "README.md", "unexpected": true})
            )
            .is_err()
        );
        assert!(
            serde_json::from_value::<WriteArgs>(serde_json::json!({
                "path": "README.md",
                "content": "",
                "unexpected": true,
            }))
            .is_err()
        );
    }
}