agent-sdk-toolkit 0.1.0-alpha.4

Optional tool-pack helpers layered over agent-sdk-core primitive contracts.
Documentation
//! Concrete workspace tool helpers layered over core tool/effect contracts. Use these
//! modules for bounded read, search, edit, write, and format-aware extraction
//! behavior under a host-selected workspace policy. Reads search local files;
//! edit/write helpers may mutate files only through explicit executor calls. This
//! file contains the office portion of that contract.
//!
use std::{
    io::{Cursor, Read},
    path::Path,
};

use quick_xml::{Reader, events::Event};
use zip::ZipArchive;

use super::{RenderedRead, add_truncation_guidance, extraction_error, legacy_office, text};
use crate::workspace::read_pipeline::{WorkspaceDocumentMetadata, WorkspaceReaderStep};

const MAX_XML_ENTRY_BYTES: u64 = 1024 * 1024;

/// Render office.
/// This parses caller-provided bytes into a bounded rendered read response and does not write
/// workspace files.
pub(super) fn render_office(
    path: &Path,
    bytes: &[u8],
    max_output_bytes: u64,
) -> Result<RenderedRead, agent_sdk_core::AgentError> {
    let extension = path
        .extension()
        .and_then(|extension| extension.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    if matches!(extension.as_str(), "doc" | "xls" | "ppt") {
        return legacy_office::render_legacy_office(path, bytes, max_output_bytes);
    }
    let mut archive = ZipArchive::new(Cursor::new(bytes))
        .map_err(|error| extraction_error("office document", error))?;
    let mut warnings = Vec::new();
    let mut sections = Vec::new();
    let entry_limit = max_output_bytes.clamp(4096, MAX_XML_ENTRY_BYTES);

    match extension.as_str() {
        "docx" => {
            push_xml_entry(
                &mut archive,
                "word/document.xml",
                "document",
                entry_limit,
                &mut sections,
                &mut warnings,
            )?;
        }
        "pptx" => {
            push_matching_xml_entries(
                &mut archive,
                "ppt/slides/slide",
                ".xml",
                "slide",
                entry_limit,
                &mut sections,
                &mut warnings,
            )?;
        }
        "xlsx" => {
            push_xml_entry(
                &mut archive,
                "xl/sharedStrings.xml",
                "shared strings",
                entry_limit,
                &mut sections,
                &mut warnings,
            )?;
            push_matching_xml_entries(
                &mut archive,
                "xl/worksheets/sheet",
                ".xml",
                "sheet",
                entry_limit,
                &mut sections,
                &mut warnings,
            )?;
        }
        _ => warnings.push(format!(
            "office/document extension .{extension} is detected, but only docx/pptx/xlsx XML extraction is implemented"
        )),
    }

    let extracted = sections.join("\n\n");
    if extracted.trim().is_empty() {
        warnings.push("document parser found no extractable XML text".to_string());
    }
    let mut rendered = text::render_text(&extracted, max_output_bytes, false, true);
    rendered.reader_pipeline = vec![
        WorkspaceReaderStep::DetectFileType,
        WorkspaceReaderStep::ExtractOfficeText,
    ];
    rendered.content_summary = Some(format!(
        "Office/document extraction completed with {} section(s), {} extracted character(s).",
        sections.len(),
        extracted.chars().count()
    ));
    rendered.document = Some(WorkspaceDocumentMetadata {
        parser: "zip-ooxml-quick-xml:0.1".to_string(),
        page_count: None,
        extracted_chars: extracted.chars().count(),
        ocr: None,
        warnings: warnings.clone(),
    });
    rendered.warnings.extend(warnings);
    add_truncation_guidance(&mut rendered);
    Ok(rendered)
}

fn push_matching_xml_entries(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    prefix: &str,
    suffix: &str,
    label: &str,
    entry_limit: u64,
    sections: &mut Vec<String>,
    warnings: &mut Vec<String>,
) -> Result<(), agent_sdk_core::AgentError> {
    let mut names = Vec::new();
    for index in 0..archive.len() {
        let file = archive
            .by_index(index)
            .map_err(|error| extraction_error("office document", error))?;
        let name = file.name().to_string();
        if name.starts_with(prefix) && name.ends_with(suffix) {
            if name.split('/').any(|part| part == "..") {
                warnings.push(format!(
                    "office entry with parent traversal skipped: {name}"
                ));
                continue;
            }
            names.push(name);
        }
    }
    names.sort();
    for (index, name) in names.iter().enumerate() {
        push_xml_entry(
            archive,
            name,
            &format!("{label} {}", index + 1),
            entry_limit,
            sections,
            warnings,
        )?;
    }
    Ok(())
}

fn push_xml_entry(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    name: &str,
    label: &str,
    entry_limit: u64,
    sections: &mut Vec<String>,
    warnings: &mut Vec<String>,
) -> Result<(), agent_sdk_core::AgentError> {
    let file = match archive.by_name(name) {
        Ok(file) => file,
        Err(error) => {
            warnings.push(format!("missing {name}: {error}"));
            return Ok(());
        }
    };
    if file.size() > entry_limit {
        warnings.push(format!(
            "skipped {name}: uncompressed entry size {} exceeds limit {entry_limit}",
            file.size()
        ));
        return Ok(());
    }
    let mut xml = String::new();
    let mut limited = file.take(entry_limit + 1);
    limited
        .read_to_string(&mut xml)
        .map_err(|error| extraction_error("office document", error))?;
    if xml.len() as u64 > entry_limit {
        warnings.push(format!(
            "skipped {name}: entry exceeded read limit {entry_limit}"
        ));
        return Ok(());
    }
    let text = xml_text(&xml).map_err(|error| extraction_error("office document xml", error))?;
    if !text.trim().is_empty() {
        sections.push(format!("[{label}]\n{text}"));
    }
    Ok(())
}

fn xml_text(xml: &str) -> Result<String, String> {
    let mut reader = Reader::from_str(xml);
    reader.config_mut().trim_text(true);
    let mut output = String::new();
    loop {
        match reader.read_event() {
            Ok(Event::Text(text)) => {
                let value = text.xml10_content().map_err(|error| error.to_string())?;
                push_word(&mut output, value.trim());
            }
            Ok(Event::CData(text)) => {
                let value = text.decode().map_err(|error| error.to_string())?;
                push_word(&mut output, value.trim());
            }
            Ok(Event::Eof) => break,
            Err(error) => return Err(error.to_string()),
            _ => {}
        }
    }
    Ok(output)
}

fn push_word(output: &mut String, value: &str) {
    if value.is_empty() {
        return;
    }
    if !output.is_empty() {
        output.push(' ');
    }
    output.push_str(value);
}